| Server IP : 104.21.73.108 / Your IP : 216.73.216.59 Web Server : Apache/2 System : Linux cat167-197.static.lnwhostname.com 5.10.0-20-amd64 #1 SMP Debian 5.10.158-2 (2022-12-13) x86_64 User : paphayomho ( 1042) PHP Version : 5.6.40 Disable Function : exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname MySQL : ON | cURL : ON | WGET : OFF | Perl : OFF | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : /home/paphayomho/domains/paphayomhospital.go.th/private_html/FDH2569/ |
Upload File : |
<!doctype html>
<html lang="th">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>UPT UC Screen</title>
<style>
:root {
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--ink: #162033;
--muted: #667085;
--line: #d9dee7;
--brand: #0f766e;
--brand-dark: #115e59;
--accent: #b42318;
--soft: #e7f5f2;
--warn: #fff7ed;
--warn-line: #fed7aa;
--shadow: 0 16px 45px rgba(22, 32, 51, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
font-family: Tahoma, "Noto Sans Thai", Arial, sans-serif;
line-height: 1.5;
}
button, input, select {
font: inherit;
}
.shell {
width: min(1180px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 40px;
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
h1 {
margin: 0 0 6px;
font-size: clamp(24px, 3vw, 34px);
letter-spacing: 0;
}
.lead {
margin: 0;
max-width: 790px;
color: var(--muted);
font-size: 15px;
}
.pill {
flex: 0 0 auto;
min-width: 170px;
border: 1px solid var(--line);
background: var(--panel);
border-radius: 999px;
padding: 9px 14px;
color: var(--brand-dark);
font-weight: 700;
text-align: center;
box-shadow: var(--shadow);
}
.band {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
box-shadow: var(--shadow);
margin-bottom: 16px;
}
.upload-row {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 16px;
align-items: stretch;
}
.dropzone {
border: 2px dashed #a8b5c7;
border-radius: 8px;
padding: 24px;
background: #fbfcfe;
display: grid;
gap: 12px;
min-height: 170px;
align-content: center;
}
.dropzone.drag {
border-color: var(--brand);
background: var(--soft);
}
.dropzone strong {
font-size: 18px;
}
.file-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.file-input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
overflow: hidden;
}
.btn, .file-label {
border: 0;
border-radius: 6px;
padding: 10px 14px;
background: var(--brand);
color: white;
font-weight: 700;
cursor: pointer;
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
text-decoration: none;
}
.btn:hover, .file-label:hover {
background: var(--brand-dark);
}
.btn.secondary {
background: #eef2f6;
color: var(--ink);
border: 1px solid var(--line);
}
.btn.secondary:hover {
background: #e3e8ef;
}
.btn.danger {
background: var(--accent);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.rule-box {
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
background: #fbfcfe;
}
.rule-box h2, .section-title {
margin: 0 0 12px;
font-size: 16px;
}
.rule-grid {
display: grid;
gap: 8px;
color: var(--muted);
font-size: 14px;
}
code {
background: #eef2f6;
border: 1px solid #d8dee8;
border-radius: 5px;
padding: 1px 5px;
color: #243247;
font-family: Consolas, "Courier New", monospace;
font-size: 0.95em;
}
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.metric {
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
background: #fbfcfe;
min-height: 94px;
}
.metric span {
display: block;
color: var(--muted);
font-size: 13px;
margin-bottom: 8px;
}
.metric strong {
display: block;
font-size: clamp(22px, 3vw, 31px);
line-height: 1.1;
}
.metric small {
color: var(--muted);
display: block;
margin-top: 7px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.status {
color: var(--muted);
font-size: 14px;
}
.alert {
border: 1px solid var(--warn-line);
background: var(--warn);
color: #7c2d12;
border-radius: 8px;
padding: 12px 14px;
margin-top: 12px;
display: none;
}
.alert.show {
display: block;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: white;
max-height: 390px;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
font-size: 13px;
}
th, td {
border-bottom: 1px solid #edf0f5;
padding: 9px 10px;
text-align: left;
white-space: nowrap;
}
th {
position: sticky;
top: 0;
background: #f8fafc;
z-index: 1;
color: #344054;
font-weight: 700;
}
tr:last-child td {
border-bottom: 0;
}
.ok { color: var(--brand-dark); font-weight: 700; }
.bad { color: var(--accent); font-weight: 700; }
.muted { color: var(--muted); }
.footer-note {
margin: 14px 0 0;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 860px) {
.topbar, .upload-row {
grid-template-columns: 1fr;
display: grid;
}
.pill {
width: 100%;
}
.metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.shell {
width: min(100vw - 20px, 1180px);
padding-top: 18px;
}
.metrics {
grid-template-columns: 1fr;
}
.dropzone, .band {
padding: 14px;
}
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div>
<h1>UPT UC Screen</h1>
<p class="lead">อัปโหลดชุด 16 แฟ้ม แล้วคัดกรองเคส ADP 15|31101 เฉพาะสิทธิ UCS ทุก subtype อายุ 15-49 ปี พร้อมรวมจำนวนเคส ยอดเงิน และส่งออกกลับเป็น 16 แฟ้มโครงสร้างเดิมสำหรับส่ง FDH</p>
</div>
<div class="pill" id="statusPill">พร้อมใช้งาน</div>
</header>
<section class="band">
<div class="upload-row">
<div class="dropzone" id="dropzone">
<strong>อัปโหลด 16 แฟ้ม `.txt` หรือ `.zip`</strong>
<span class="muted">เลือกไฟล์ ZIP, เลือกหลายไฟล์พร้อมกัน หรือเลือกทั้งโฟลเดอร์ชุด FDH ได้</span>
<div class="file-actions">
<label class="file-label" for="fileInput">เลือกไฟล์</label>
<input class="file-input" id="fileInput" type="file" multiple accept=".txt,.zip,text/plain,application/zip">
<label class="file-label" for="folderInput">เลือกโฟลเดอร์</label>
<input class="file-input" id="folderInput" type="file" multiple webkitdirectory>
<button class="btn secondary" id="clearButton" type="button">ล้างข้อมูล</button>
</div>
<span class="status" id="uploadStatus">ยังไม่ได้อัปโหลดไฟล์</span>
</div>
<div class="rule-box">
<h2>เงื่อนไขคัดกรอง</h2>
<div class="rule-grid">
<div>จาก <code>ADP.txt</code>: <code>TYPE = 15</code> และ <code>CODE = 31101</code></div>
<div>จาก <code>INS.txt</code>: <code>INSCL = UCS</code> ทุก <code>SUBTYPE</code></div>
<div>อายุ <code>15-49 ปี</code> จาก <code>PAT.DOB</code> เทียบกับวันที่บริการของเคส</div>
<div>ตัดเคสที่มี <code>ODX.DIAG = Z321</code> ออก</div>
<div>เติม <code>ODX.DIAG = Z320</code> และ <code>ADP 4|30014</code> ได้อัตโนมัติ หากเคสเข้าเกณฑ์แต่ยังไม่มี</div>
<div>ลบ refer ตอน export: <code>ORF.txt</code> เหลือ header, ตัด <code>ADP 3|S1801/S1802</code>, ตัด <code>CHA J1|435</code> และหัก <code>CHT.TOTAL</code> ตามยอดที่ตัด</div>
<div>ยอดเงินรวมแสดงทั้ง <code>ADP.TOTAL</code> ของรายการที่ตรงเงื่อนไข และ <code>CHT.TOTAL</code> ของเคสที่ถูกดึงเข้า</div>
</div>
</div>
</div>
<div class="alert" id="alertBox"></div>
</section>
<section class="band">
<div class="toolbar">
<h2 class="section-title">ผลรวม</h2>
<button class="btn" id="exportButton" type="button" disabled>Export ZIP 16 แฟ้ม</button>
</div>
<div class="metrics">
<div class="metric">
<span>เคสเข้าเกณฑ์</span>
<strong id="caseCount">0</strong>
<small>นับ visit ไม่ซ้ำจาก AN/SEQ</small>
</div>
<div class="metric">
<span>ยอดเงิน CHT.TOTAL</span>
<strong id="chtTotal">0.00</strong>
<small>บาท</small>
</div>
<div class="metric">
<span>ยอด ADP 15|31101</span>
<strong id="adpTotal">0.00</strong>
<small>บาท</small>
</div>
<div class="metric">
<span>แถวที่จะ export</span>
<strong id="rowTotal">0</strong>
<small>รวมทุกแฟ้ม</small>
</div>
<div class="metric">
<span>รายการที่ยังต้องเติม</span>
<strong id="autofillCount">0</strong>
<small>ODX Z320 + ADP 4|30014 + CHA J1|75 + CHT</small>
</div>
<div class="metric">
<span>Autofill ADP 4|30014</span>
<strong id="autofillAdpCount">0</strong>
<small>แถว</small>
</div>
<div class="metric">
<span>ลบ refer</span>
<strong id="referCleanupCount">0</strong>
<small>แถว</small>
</div>
</div>
<div class="file-actions" style="margin-top: 12px;">
<button class="btn secondary" id="autofillButton" type="button" disabled>Autofill ODX + ADP + CHA + CHT</button>
<span class="status" id="autofillStatus">ยังไม่มีรายการที่ต้องเติม</span>
</div>
<div class="file-actions" style="margin-top: 12px;">
<button class="btn secondary" id="referCleanupButton" type="button" disabled>ลบ refer</button>
<span class="status" id="referCleanupStatus">ยังไม่ได้ลบ refer</span>
</div>
</section>
<section class="band">
<h2 class="section-title">สถานะแฟ้ม</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>แฟ้ม</th>
<th>สถานะ</th>
<th>แถวต้นฉบับ</th>
<th>แถวหลังกรอง</th>
<th>หมายเหตุ</th>
</tr>
</thead>
<tbody id="fileTable"></tbody>
</table>
</div>
</section>
<section class="band">
<h2 class="section-title">ผล Autofill</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>#</th>
<th>สถานะ</th>
<th>HN</th>
<th>แฟ้ม</th>
<th>วันที่</th>
<th>รหัส</th>
<th>รายละเอียด</th>
<th>SEQ</th>
<th>ยอด</th>
</tr>
</thead>
<tbody id="autofillTable"></tbody>
</table>
</div>
</section>
<section class="band">
<h2 class="section-title">ผลลบ refer</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>#</th>
<th>สถานะ</th>
<th>HN</th>
<th>แฟ้ม</th>
<th>วันที่</th>
<th>รหัส</th>
<th>รายละเอียด</th>
<th>SEQ</th>
<th>ยอด</th>
</tr>
</thead>
<tbody id="referCleanupTable"></tbody>
</table>
</div>
</section>
<section class="band">
<h2 class="section-title">Preview เคสเข้าเกณฑ์</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>#</th>
<th>ชนิด</th>
<th>HN</th>
<th>AN</th>
<th>SEQ</th>
<th>เงื่อนไข</th>
<th>ยอด CHT</th>
</tr>
</thead>
<tbody id="caseTable"></tbody>
</table>
</div>
<p class="footer-note">หมายเหตุ: เครื่องมือนี้ทำงานใน browser ฝั่งเครื่องผู้ใช้ ไฟล์ไม่ถูกส่งออกไปยัง server</p>
</section>
</main>
<script>
const EXPECTED_FILES = [
"ADP", "AER", "CHA", "CHT", "DRU", "IDX", "INS", "IOP",
"IPD", "IRF", "LVD", "ODX", "OOP", "OPD", "ORF", "PAT"
];
const TARGET_ADP_TYPE = "15";
const TARGET_ADP_CODE = "31101";
const TARGET_INSCL = "UCS";
const EXCLUDED_ODX_DIAG = "Z321";
const AUTOFILL_ODX_DIAG = "Z320";
const AUTOFILL_ADP_TYPE = "4";
const AUTOFILL_ADP_CODE = "30014";
const AUTOFILL_ADP_RATE = "75.00";
const AUTOFILL_CHA_CHRGITEM = "J1";
const AUTOFILL_CHARGE_CENTS = 7500;
const REFER_ADP_REMOVE = new Set(["3|S1801", "3|S1802"]);
const REFER_CHA_CHRGITEM = "J1";
const REFER_CHA_AMOUNT_CENTS = 43500;
const MIN_AGE = 15;
const MAX_AGE = 49;
const FIELD_NAMES = [
"HN", "AN", "SEQ", "PERSON_ID", "TYPE", "CODE", "QTY", "RATE", "TOTAL",
"INSCL", "SUBTYPE", "DOB", "DATEOPD", "DATEADM", "DATE", "DATEDX",
"CLINIC", "ITEMSRC", "PROVIDER", "BI", "DIAG", "DXTYPE", "DRDX",
"CHRGITEM", "AMOUNT"
];
const asciiDecoder = new TextDecoder("iso-8859-1");
const utf8Decoder = new TextDecoder("utf-8");
const textEncoder = new TextEncoder();
const state = {
tables: new Map(),
result: null,
autofillEnabled: false,
referCleanupEnabled: false
};
const el = {
dropzone: document.querySelector("#dropzone"),
fileInput: document.querySelector("#fileInput"),
folderInput: document.querySelector("#folderInput"),
clearButton: document.querySelector("#clearButton"),
autofillButton: document.querySelector("#autofillButton"),
referCleanupButton: document.querySelector("#referCleanupButton"),
exportButton: document.querySelector("#exportButton"),
uploadStatus: document.querySelector("#uploadStatus"),
statusPill: document.querySelector("#statusPill"),
alertBox: document.querySelector("#alertBox"),
caseCount: document.querySelector("#caseCount"),
chtTotal: document.querySelector("#chtTotal"),
adpTotal: document.querySelector("#adpTotal"),
rowTotal: document.querySelector("#rowTotal"),
autofillCount: document.querySelector("#autofillCount"),
autofillAdpCount: document.querySelector("#autofillAdpCount"),
referCleanupCount: document.querySelector("#referCleanupCount"),
autofillStatus: document.querySelector("#autofillStatus"),
referCleanupStatus: document.querySelector("#referCleanupStatus"),
fileTable: document.querySelector("#fileTable"),
autofillTable: document.querySelector("#autofillTable"),
referCleanupTable: document.querySelector("#referCleanupTable"),
caseTable: document.querySelector("#caseTable")
};
function baseName(file) {
const raw = file.webkitRelativePath || file.name;
const name = raw.split(/[\\/]/).pop() || raw;
return name.replace(/\.[^.]+$/, "").toUpperCase();
}
function decodeLine(bytes) {
return asciiDecoder.decode(bytes);
}
function splitLines(bytes) {
const lines = [];
let start = 0;
let newline = new Uint8Array([13, 10]);
for (let i = 0; i < bytes.length; i += 1) {
if (bytes[i] !== 10) continue;
let end = i;
if (end > start && bytes[end - 1] === 13) end -= 1;
lines.push(bytes.slice(start, end));
start = i + 1;
}
if (start < bytes.length) lines.push(bytes.slice(start));
if (!bytes.includes(13)) newline = new Uint8Array([10]);
if (lines.length === 0) lines.push(new Uint8Array());
return { lines, newline };
}
function selectedValues(line, indexes) {
const values = {};
if (!indexes.size) return values;
const text = decodeLine(line);
const parts = text.split("|");
for (const [field, index] of indexes) values[field] = (parts[index] || "").trim();
return values;
}
function parseBytes(rawName, bytes) {
const name = rawName.split(/[\\/]/).pop().replace(/\.[^.]+$/, "").toUpperCase();
const { lines, newline } = splitLines(bytes);
const headers = decodeLine(lines[0] || new Uint8Array()).replace(/^\uFEFF/, "").split("|");
const index = new Map(headers.map((h, i) => [h.trim().toUpperCase(), i]));
const wantedIndexes = new Map();
for (const field of FIELD_NAMES) {
if (index.has(field)) wantedIndexes.set(field, index.get(field));
}
const rows = [];
for (let i = 1; i < lines.length; i += 1) {
if (lines[i].length === 0) continue;
rows.push({
line: lines[i],
rowNumber: i + 1,
values: selectedValues(lines[i], wantedIndexes)
});
}
return {
name,
originalName: `${name}.txt`,
bytes,
newline,
headerLine: lines[0] || new Uint8Array(),
headers,
index,
rows,
filteredRows: [],
note: ""
};
}
async function parseFile(file) {
const bytes = new Uint8Array(await file.arrayBuffer());
return parseBytes(baseName(file), bytes);
}
function readU16(bytes, offset) {
return bytes[offset] | (bytes[offset + 1] << 8);
}
function readU32(bytes, offset) {
return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0;
}
function decodeZipName(bytes, utf8) {
return (utf8 ? utf8Decoder : asciiDecoder).decode(bytes);
}
async function inflateRaw(bytes) {
if (!("DecompressionStream" in window)) {
throw new Error("Browser นี้ยังไม่รองรับการแตก ZIP แบบ deflate กรุณาแตก ZIP ก่อนแล้วอัปโหลด .txt");
}
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
function findEndOfCentralDirectory(bytes) {
const min = Math.max(0, bytes.length - 0xffff - 22);
for (let i = bytes.length - 22; i >= min; i -= 1) {
if (readU32(bytes, i) === 0x06054b50) return i;
}
return -1;
}
async function extractZipTextFiles(file) {
const bytes = new Uint8Array(await file.arrayBuffer());
const eocd = findEndOfCentralDirectory(bytes);
if (eocd < 0) throw new Error(`${file.name} ไม่ใช่ ZIP ที่อ่านได้`);
const entryCount = readU16(bytes, eocd + 10);
let offset = readU32(bytes, eocd + 16);
const entries = [];
for (let i = 0; i < entryCount; i += 1) {
if (readU32(bytes, offset) !== 0x02014b50) break;
const flags = readU16(bytes, offset + 8);
const method = readU16(bytes, offset + 10);
const compressedSize = readU32(bytes, offset + 20);
const nameLength = readU16(bytes, offset + 28);
const extraLength = readU16(bytes, offset + 30);
const commentLength = readU16(bytes, offset + 32);
const localOffset = readU32(bytes, offset + 42);
const nameBytes = bytes.slice(offset + 46, offset + 46 + nameLength);
const entryName = decodeZipName(nameBytes, Boolean(flags & 0x0800)).replace(/\\/g, "/");
offset += 46 + nameLength + extraLength + commentLength;
if (!/\.txt$/i.test(entryName) || entryName.endsWith("/")) continue;
if (readU32(bytes, localOffset) !== 0x04034b50) continue;
const localNameLength = readU16(bytes, localOffset + 26);
const localExtraLength = readU16(bytes, localOffset + 28);
const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
const compressed = bytes.slice(dataOffset, dataOffset + compressedSize);
let data;
if (method === 0) {
data = compressed;
} else if (method === 8) {
data = await inflateRaw(compressed);
} else {
throw new Error(`${entryName} ใน ${file.name} ใช้วิธีบีบอัดที่ยังไม่รองรับ (${method})`);
}
entries.push({ name: entryName, bytes: data, source: file.name });
}
return entries;
}
async function collectTextInputs(files) {
const entries = [];
for (const file of Array.from(files || [])) {
if (/\.zip$/i.test(file.name)) {
entries.push(...await extractZipTextFiles(file));
} else if (/\.txt$/i.test(file.name)) {
entries.push({
name: file.webkitRelativePath || file.name,
bytes: new Uint8Array(await file.arrayBuffer()),
source: file.name
});
}
}
return entries;
}
async function loadFiles(files) {
setBusy(true, "กำลังอ่านไฟล์...");
clearAlert();
try {
const list = await collectTextInputs(files);
if (!list.length) {
setBusy(false, "พร้อมใช้งาน");
showAlert("ไม่พบไฟล์ .txt ในไฟล์ที่เลือก กรุณาเลือก ZIP หรือชุด 16 แฟ้ม");
return;
}
const parsed = list.map(item => parseBytes(item.name, item.bytes));
const duplicateNames = Array.from(parsed.reduce((counts, table) => {
if (EXPECTED_FILES.includes(table.name)) counts.set(table.name, (counts.get(table.name) || 0) + 1);
return counts;
}, new Map()).entries()).filter(([, count]) => count > 1).map(([name]) => `${name}.txt`);
if (duplicateNames.length) {
throw new Error(`พบแฟ้มซ้ำ: ${duplicateNames.join(", ")} กรุณาใช้ ZIP ที่มี 16 แฟ้มเพียงชุดเดียว ห้ามรวมหลายโฟลเดอร์ไว้ใน ZIP เดียว`);
}
state.tables = new Map();
state.autofillEnabled = false;
state.referCleanupEnabled = false;
for (const table of parsed) {
if (!EXPECTED_FILES.includes(table.name)) continue;
state.tables.set(table.name, table);
}
analyze();
setBusy(false, "วิเคราะห์แล้ว");
el.uploadStatus.textContent = `อ่านไฟล์ .txt ${list.length.toLocaleString("th-TH")} ไฟล์, ใช้ในชุด 16 แฟ้ม ${state.tables.size.toLocaleString("th-TH")} ไฟล์`;
} catch (error) {
state.tables = new Map();
state.result = null;
state.autofillEnabled = false;
state.referCleanupEnabled = false;
render();
setBusy(false, "พร้อมใช้งาน");
showAlert(error.message || "อ่าน ZIP ไม่สำเร็จ");
}
}
function visitKey(row) {
const an = clean(row.AN);
const seq = clean(row.SEQ);
if (an) return `AN:${an}`;
if (seq) return `SEQ:${seq}`;
return "";
}
function clean(value) {
return String(value || "").trim();
}
function normalizeType(value) {
const text = clean(value);
const numeric = Number(text);
return Number.isFinite(numeric) ? String(numeric) : text;
}
function normalizeCode(value) {
return clean(value).toUpperCase();
}
function normalizeDiag(value) {
return normalizeCode(value).replace(/\./g, "");
}
function toCents(value) {
const text = clean(value).replace(/,/g, "");
if (!text) return 0;
const negative = text.startsWith("-");
const normalized = negative ? text.slice(1) : text;
const [whole, fraction = ""] = normalized.split(".");
const cents = (parseInt(whole || "0", 10) * 100) + parseInt((fraction + "00").slice(0, 2), 10);
return Number.isFinite(cents) ? (negative ? -cents : cents) : 0;
}
function parseFdhDate(value) {
const text = clean(value).replace(/\D/g, "");
if (text.length !== 8) return null;
const year = Number(text.slice(0, 4));
const month = Number(text.slice(4, 6));
const day = Number(text.slice(6, 8));
if (!year || !month || !day) return null;
const date = new Date(year, month - 1, day);
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null;
return date;
}
function ageOnDate(dobValue, serviceDateValue) {
const dob = parseFdhDate(dobValue);
const service = parseFdhDate(serviceDateValue);
if (!dob || !service) return null;
let age = service.getFullYear() - dob.getFullYear();
const beforeBirthday = service.getMonth() < dob.getMonth()
|| (service.getMonth() === dob.getMonth() && service.getDate() < dob.getDate());
if (beforeBirthday) age -= 1;
return age;
}
function inAgeRange(age) {
return age !== null && age >= MIN_AGE && age <= MAX_AGE;
}
function addReason(caseMap, key, row, reason) {
if (!key) return;
const found = caseMap.get(key) || {
key,
type: key.startsWith("AN:") ? "IPD" : "OPD",
hn: clean(row.HN),
an: clean(row.AN),
seq: clean(row.SEQ),
personIds: new Set(),
reasons: new Set(),
chtCents: 0,
adpCents: 0,
age: "",
serviceDate: ""
};
if (clean(row.HN)) found.hn = clean(row.HN);
if (clean(row.AN)) found.an = clean(row.AN);
if (clean(row.SEQ)) found.seq = clean(row.SEQ);
if (clean(row.PERSON_ID)) found.personIds.add(clean(row.PERSON_ID));
found.reasons.add(reason);
caseMap.set(key, found);
}
function analyze() {
const candidates = new Map();
const patientByHn = new Map();
const patientByPerson = new Map();
const insByKey = new Map();
const adp30014Keys = new Set();
const cha30014Keys = new Set();
const excludedOdxKeys = new Set();
const odxZ320Keys = new Set();
const odxRefByKey = new Map();
const serviceDateByKey = new Map();
const clinicByKey = new Map();
const caseMap = new Map();
const matchedKeys = new Set();
const matchedHn = new Set();
const matchedAn = new Set();
const matchedSeq = new Set();
const matchedPerson = new Set();
let adpCents = 0;
const rememberServiceDate = (key, value) => {
if (!key || serviceDateByKey.has(key) || !clean(value)) return;
serviceDateByKey.set(key, clean(value));
};
const rememberClinic = (key, value) => {
if (!key || clinicByKey.has(key) || !clean(value)) return;
clinicByKey.set(key, clean(value));
};
const pat = state.tables.get("PAT");
if (pat) {
for (const row of pat.rows) {
const hn = clean(row.values.HN);
const personId = clean(row.values.PERSON_ID);
const patient = {
hn,
personId,
dob: clean(row.values.DOB)
};
if (hn) patientByHn.set(hn, patient);
if (personId) patientByPerson.set(personId, patient);
}
}
for (const name of ["ADP", "IPD", "OPD", "CHT"]) {
const table = state.tables.get(name);
if (!table) continue;
for (const row of table.rows) {
const key = visitKey(row.values);
rememberServiceDate(key, row.values.DATEOPD || row.values.DATEADM || row.values.DATE);
rememberClinic(key, row.values.CLINIC);
}
}
const ins = state.tables.get("INS");
if (ins) {
for (const row of ins.rows) {
const key = visitKey(row.values);
if (!key) continue;
const inscl = normalizeCode(row.values.INSCL);
if (inscl !== TARGET_INSCL) continue;
insByKey.set(key, row.values);
}
}
const odx = state.tables.get("ODX");
if (odx) {
for (const row of odx.rows) {
const key = visitKey(row.values);
if (!key) continue;
if (!odxRefByKey.has(key)) odxRefByKey.set(key, row.values);
rememberServiceDate(key, row.values.DATEDX);
rememberClinic(key, row.values.CLINIC);
const diag = normalizeDiag(row.values.DIAG);
if (diag === EXCLUDED_ODX_DIAG) excludedOdxKeys.add(key);
if (diag === AUTOFILL_ODX_DIAG) odxZ320Keys.add(key);
}
}
const adp = state.tables.get("ADP");
if (adp) {
for (const row of adp.rows) {
const type = normalizeType(row.values.TYPE);
const code = normalizeCode(row.values.CODE);
const key = visitKey(row.values);
if (type === AUTOFILL_ADP_TYPE && code === AUTOFILL_ADP_CODE && key) adp30014Keys.add(key);
if (type === TARGET_ADP_TYPE && code === TARGET_ADP_CODE) {
if (!key) continue;
const found = candidates.get(key) || {
key,
rows: [],
source: row.values,
hn: clean(row.values.HN),
an: clean(row.values.AN),
seq: clean(row.values.SEQ),
adpCents: 0,
serviceDate: clean(row.values.DATEOPD)
};
found.rows.push(row.values);
if (!found.source) found.source = row.values;
found.adpCents += toCents(row.values.TOTAL);
if (clean(row.values.HN)) found.hn = clean(row.values.HN);
if (clean(row.values.AN)) found.an = clean(row.values.AN);
if (clean(row.values.SEQ)) found.seq = clean(row.values.SEQ);
if (clean(row.values.DATEOPD)) found.serviceDate = clean(row.values.DATEOPD);
candidates.set(key, found);
}
}
}
const cha = state.tables.get("CHA");
if (cha) {
for (const row of cha.rows) {
const key = visitKey(row.values);
if (!key) continue;
if (normalizeCode(row.values.CHRGITEM) === AUTOFILL_CHA_CHRGITEM
&& toCents(row.values.AMOUNT) === AUTOFILL_CHARGE_CENTS) {
cha30014Keys.add(key);
}
}
}
for (const candidate of candidates.values()) {
if (excludedOdxKeys.has(candidate.key)) continue;
const insRow = insByKey.get(candidate.key);
if (!insRow) continue;
const hn = candidate.hn || clean(insRow.HN);
const personId = clean(insRow.PERSON_ID);
const patient = (hn && patientByHn.get(hn)) || (personId && patientByPerson.get(personId));
const serviceDate = candidate.serviceDate || serviceDateByKey.get(candidate.key);
const age = patient ? ageOnDate(patient.dob, serviceDate) : null;
if (!inAgeRange(age)) continue;
const seed = {
HN: hn,
AN: candidate.an || clean(insRow.AN),
SEQ: candidate.seq || clean(insRow.SEQ),
PERSON_ID: patient?.personId || personId
};
addReason(caseMap, candidate.key, seed, `ADP ${TARGET_ADP_TYPE}|${TARGET_ADP_CODE}`);
addReason(caseMap, candidate.key, seed, `INS ${TARGET_INSCL}`);
addReason(caseMap, candidate.key, seed, `Age ${age}`);
const item = caseMap.get(candidate.key);
item.adpCents = candidate.adpCents;
item.age = age;
item.serviceDate = serviceDate || "";
item.clinic = clinicByKey.get(candidate.key) || "";
item.odxRef = odxRefByKey.get(candidate.key) || null;
item.adpRef = candidate.source || null;
item.hasZ320 = odxZ320Keys.has(candidate.key);
item.hasAdp30014 = adp30014Keys.has(candidate.key);
item.hasCha30014 = cha30014Keys.has(candidate.key);
item.hasCht = false;
adpCents += candidate.adpCents;
matchedKeys.add(candidate.key);
}
const cht = state.tables.get("CHT");
if (cht) {
for (const row of cht.rows) {
const key = visitKey(row.values);
if (!caseMap.has(key)) continue;
const item = caseMap.get(key);
item.chtCents += toCents(row.values.TOTAL);
item.hasCht = true;
if (clean(row.values.HN)) item.hn = clean(row.values.HN);
if (clean(row.values.AN)) item.an = clean(row.values.AN);
if (clean(row.values.SEQ)) item.seq = clean(row.values.SEQ);
if (clean(row.values.PERSON_ID)) item.personIds.add(clean(row.values.PERSON_ID));
}
}
for (const item of caseMap.values()) {
if (item.hn) matchedHn.add(item.hn);
if (item.an) matchedAn.add(item.an);
if (item.seq) matchedSeq.add(item.seq);
for (const personId of item.personIds) matchedPerson.add(personId);
}
for (const table of state.tables.values()) {
table.filteredRows = table.rows.filter(row => includeRow(table.name, row.values, matchedKeys, matchedHn, matchedAn, matchedSeq, matchedPerson));
}
const cases = Array.from(caseMap.values()).sort((a, b) => a.key.localeCompare(b.key));
const referCleanup = analyzeReferCleanup();
for (const item of cases) item.referDeductionCents = referCleanup.chaDeductions.get(item.key) || 0;
const autofillOdxCases = cases.filter(item => !item.hasZ320);
const autofillAdpCases = cases.filter(item => !item.hasAdp30014);
const autofillChaCases = cases.filter(item => !item.hasCha30014);
const autofillChtCases = autofillChaCases.filter(item => item.hasCht);
const autofillOdxRows = autofillOdxCases.map(item => makeAutofillOdxRow(item)).filter(Boolean);
const autofillAdpRows = autofillAdpCases.map(item => makeAutofillAdp30014Row(item)).filter(Boolean);
const autofillChaRows = autofillChaCases.map(item => makeAutofillCha30014Row(item)).filter(Boolean);
const autofillChtRows = autofillChtCases.map(item => makeAutofillChtAdjustmentRow(item));
const autofillChtAdjustments = new Map(autofillChtCases.map(item => [item.key, AUTOFILL_CHARGE_CENTS]));
const autofillCases = Array.from(new Set([...autofillOdxCases, ...autofillAdpCases, ...autofillChaCases]));
const autofillRows = [...autofillOdxRows, ...autofillAdpRows, ...autofillChaRows, ...autofillChtRows];
const autofillAddedRows = [...autofillOdxRows, ...autofillAdpRows, ...autofillChaRows];
const chtCents = cases.reduce((sum, item) => sum + item.chtCents, 0);
const adjustedChtCents = cases.reduce((sum, item) => {
const addition = state.autofillEnabled ? (autofillChtAdjustments.get(item.key) || 0) : 0;
const deduction = state.referCleanupEnabled ? item.referDeductionCents : 0;
return sum + item.chtCents + addition - deduction;
}, 0);
const baseRowTotal = Array.from(state.tables.values()).reduce((sum, table) => sum + table.filteredRows.length, 0);
const rowTotal = baseRowTotal - (state.referCleanupEnabled ? referCleanup.removedRows : 0) + (state.autofillEnabled ? autofillAddedRows.length : 0);
const missing = EXPECTED_FILES.filter(name => !state.tables.has(name));
state.result = {
cases,
autofillCases,
autofillOdxCases,
autofillAdpCases,
autofillChaCases,
autofillChtCases,
autofillOdxRows,
autofillAdpRows,
autofillChaRows,
autofillChtRows,
autofillChtAdjustments,
autofillAddedRows,
autofillRows,
referCleanup,
adpCents,
chtCents: adjustedChtCents,
originalChtCents: chtCents,
rowTotal,
missing
};
render();
}
function analyzeReferCleanup() {
const adp = state.tables.get("ADP");
const cha = state.tables.get("CHA");
const cht = state.tables.get("CHT");
const orf = state.tables.get("ORF");
const chaDeductions = new Map();
let adpRows = 0;
let chaRows = 0;
let chtRows = 0;
let chtCents = 0;
let orfRows = orf ? orf.filteredRows.length : 0;
const previewRows = [];
if (adp) {
for (const row of adp.filteredRows) {
if (!isReferAdpRow(row.values)) continue;
adpRows += 1;
previewRows.push(makeReferPreviewRow("ADP", row.values, "ลบ", typeCodeKey(row.values), "TYPE|CODE ตรงรายการ refer", row.values.TOTAL));
}
}
if (cha) {
for (const row of cha.filteredRows) {
if (!isReferChaRow(row.values)) continue;
chaRows += 1;
const key = visitKey(row.values);
if (key) chaDeductions.set(key, (chaDeductions.get(key) || 0) + REFER_CHA_AMOUNT_CENTS);
previewRows.push(makeReferPreviewRow("CHA", row.values, "ลบ", `${REFER_CHA_CHRGITEM}|435`, "CHRGITEM|AMOUNT ตรงรายการ refer", row.values.AMOUNT));
}
}
if (cht) {
const adjustedKeys = new Set();
for (const row of cht.filteredRows) {
const key = visitKey(row.values);
if (adjustedKeys.has(key)) continue;
const deduction = chaDeductions.get(key) || 0;
if (!deduction) continue;
adjustedKeys.add(key);
chtRows += 1;
chtCents += deduction;
previewRows.push(makeReferPreviewRow("CHT", row.values, "หักยอด", "TOTAL", `หัก ${formatMoney(deduction)} จาก TOTAL`, `-${formatMoney(deduction)}`));
}
}
if (orf) {
for (const row of orf.filteredRows) {
previewRows.push(makeReferPreviewRow("ORF", row.values, "ลบ", "ORF", "เหลือเฉพาะ header", ""));
}
}
return {
adpRows,
chaRows,
chtRows,
chtCents,
orfRows,
removedRows: adpRows + chaRows + orfRows,
chaDeductions,
previewRows
};
}
function makeReferPreviewRow(file, row, action, code, detail, total) {
return {
FILE: file,
ACTION: action,
HN: clean(row.HN),
DATE: clean(row.DATEOPD || row.DATE || row.DATEDX),
CODE: clean(code),
DETAIL: clean(detail),
SEQ: clean(row.SEQ),
TOTAL: clean(total)
};
}
function includeRow(fileName, row, matchedKeys, matchedHn, matchedAn, matchedSeq, matchedPerson) {
if (!matchedKeys.size) return false;
const key = visitKey(row);
if (fileName === "INS") return Boolean(key && matchedKeys.has(key));
if (key && matchedKeys.has(key)) return true;
const hn = clean(row.HN);
const an = clean(row.AN);
const seq = clean(row.SEQ);
const personId = clean(row.PERSON_ID);
if (an && matchedAn.has(an)) return true;
if (seq && matchedSeq.has(seq)) return true;
if (fileName === "PAT") {
return (hn && matchedHn.has(hn)) || (personId && matchedPerson.has(personId));
}
return false;
}
function typeCodeKey(row) {
return `${normalizeType(row.TYPE)}|${normalizeCode(row.CODE)}`;
}
function isReferAdpRow(row) {
return REFER_ADP_REMOVE.has(typeCodeKey(row));
}
function isReferChaRow(row) {
return normalizeCode(row.CHRGITEM) === REFER_CHA_CHRGITEM && toCents(row.AMOUNT) === REFER_CHA_AMOUNT_CENTS;
}
function centsToField(cents) {
return (cents / 100).toFixed(2);
}
function rowLineWithOverrides(table, row, overrides) {
const parts = decodeLine(row.line).split("|");
for (const [field, value] of Object.entries(overrides)) {
const index = table.index.get(field);
if (index !== undefined) parts[index] = clean(value);
}
return textEncoder.encode(parts.join("|"));
}
function makeAutofillOdxRow(item) {
const odx = state.tables.get("ODX");
if (!odx) return null;
const values = new Array(odx.headers.length).fill("");
const ref = item.odxRef || {};
const set = (field, value) => {
const index = odx.index.get(field);
if (index !== undefined) values[index] = clean(value);
};
set("HN", item.hn || ref.HN);
set("DATEDX", item.serviceDate || ref.DATEDX);
set("CLINIC", item.clinic || ref.CLINIC || "000");
set("DIAG", AUTOFILL_ODX_DIAG);
set("DXTYPE", ref.DXTYPE || "1");
set("DRDX", ref.DRDX || "");
set("PERSON_ID", Array.from(item.personIds || [])[0] || ref.PERSON_ID || "");
set("SEQ", item.seq || ref.SEQ);
return {
line: textEncoder.encode(values.join("|")),
values: {
FILE: "ODX",
HN: values[odx.index.get("HN")] || "",
DATE: values[odx.index.get("DATEDX")] || "",
CODE: AUTOFILL_ODX_DIAG,
DETAIL: `CLINIC=${values[odx.index.get("CLINIC")] || ""} DXTYPE=${values[odx.index.get("DXTYPE")] || ""}`,
SEQ: values[odx.index.get("SEQ")] || "",
TOTAL: ""
}
};
}
function makeAutofillAdp30014Row(item) {
const adp = state.tables.get("ADP");
if (!adp) return null;
const values = new Array(adp.headers.length).fill("");
const ref = item.adpRef || {};
const set = (field, value) => {
const index = adp.index.get(field);
if (index !== undefined) values[index] = clean(value);
};
set("HN", item.hn || ref.HN);
set("AN", item.an || ref.AN);
set("DATEOPD", item.serviceDate || ref.DATEOPD);
set("TYPE", AUTOFILL_ADP_TYPE);
set("CODE", AUTOFILL_ADP_CODE);
set("QTY", "1");
set("RATE", AUTOFILL_ADP_RATE);
set("SEQ", item.seq || ref.SEQ);
set("TOTCOPAY", "0.00");
set("TOTAL", AUTOFILL_ADP_RATE);
set("BI", ref.BI || "000");
set("CLINIC", item.clinic || ref.CLINIC || "000");
set("ITEMSRC", ref.ITEMSRC || "2");
set("PROVIDER", ref.PROVIDER || "");
return {
line: textEncoder.encode(values.join("|")),
values: {
FILE: "ADP",
HN: values[adp.index.get("HN")] || "",
DATE: values[adp.index.get("DATEOPD")] || "",
CODE: `${AUTOFILL_ADP_TYPE}|${AUTOFILL_ADP_CODE}`,
DETAIL: `QTY=1 RATE=${AUTOFILL_ADP_RATE}`,
SEQ: values[adp.index.get("SEQ")] || "",
TOTAL: AUTOFILL_ADP_RATE
}
};
}
function makeAutofillCha30014Row(item) {
const cha = state.tables.get("CHA");
if (!cha) return null;
const values = new Array(cha.headers.length).fill("");
const set = (field, value) => {
const index = cha.index.get(field);
if (index !== undefined) values[index] = clean(value);
};
set("HN", item.hn);
set("AN", item.an);
set("DATE", item.serviceDate);
set("CHRGITEM", AUTOFILL_CHA_CHRGITEM);
set("AMOUNT", centsToField(AUTOFILL_CHARGE_CENTS));
set("PERSON_ID", Array.from(item.personIds || [])[0] || "");
set("SEQ", item.seq);
return {
line: textEncoder.encode(values.join("|")),
values: {
FILE: "CHA",
HN: values[cha.index.get("HN")] || "",
DATE: values[cha.index.get("DATE")] || "",
CODE: `${AUTOFILL_CHA_CHRGITEM}|${centsToField(AUTOFILL_CHARGE_CENTS)}`,
DETAIL: "เพิ่มค่าบริการ UPT ให้ยอดค่าใช้จ่ายสัมพันธ์กับ ADP 4|30014",
SEQ: values[cha.index.get("SEQ")] || "",
TOTAL: centsToField(AUTOFILL_CHARGE_CENTS)
}
};
}
function makeAutofillChtAdjustmentRow(item) {
return {
line: null,
values: {
FILE: "CHT",
HN: item.hn,
DATE: item.serviceDate,
CODE: "TOTAL",
DETAIL: `บวก ${centsToField(AUTOFILL_CHARGE_CENTS)} เข้า TOTAL`,
SEQ: item.seq,
TOTAL: `+${centsToField(AUTOFILL_CHARGE_CENTS)}`
}
};
}
function render() {
const result = state.result || { cases: [], autofillCases: [], autofillOdxRows: [], autofillAdpRows: [], autofillChaRows: [], autofillChtRows: [], autofillAddedRows: [], autofillRows: [], autofillChtAdjustments: new Map(), referCleanup: { adpRows: 0, chaRows: 0, chtRows: 0, chtCents: 0, orfRows: 0, removedRows: 0, previewRows: [] }, adpCents: 0, chtCents: 0, rowTotal: 0, missing: EXPECTED_FILES };
el.caseCount.textContent = result.cases.length.toLocaleString("th-TH");
el.chtTotal.textContent = formatMoney(result.chtCents);
el.adpTotal.textContent = formatMoney(result.adpCents);
el.rowTotal.textContent = result.rowTotal.toLocaleString("th-TH");
el.autofillCount.textContent = result.autofillRows.length.toLocaleString("th-TH");
el.autofillAdpCount.textContent = result.autofillAdpRows.length.toLocaleString("th-TH");
el.referCleanupCount.textContent = result.referCleanup.previewRows.length.toLocaleString("th-TH");
const hasPendingAutofill = result.autofillRows.length > 0 && !state.autofillEnabled;
el.exportButton.disabled = !result.cases.length || hasPendingAutofill;
el.autofillButton.disabled = !result.autofillRows.length || state.autofillEnabled
|| !state.tables.has("ODX") || !state.tables.has("ADP") || !state.tables.has("CHA") || !state.tables.has("CHT");
el.referCleanupButton.disabled = !result.referCleanup.previewRows.length || state.referCleanupEnabled;
el.autofillStatus.textContent = state.autofillEnabled
? `สำเร็จ: ODX ${result.autofillOdxRows.length.toLocaleString("th-TH")}, ADP ${result.autofillAdpRows.length.toLocaleString("th-TH")}, CHA ${result.autofillChaRows.length.toLocaleString("th-TH")} และปรับ CHT ${result.autofillChtRows.length.toLocaleString("th-TH")} เคส รอ export`
: (result.autofillRows.length ? `พบรายการที่ต้องเติม/ปรับ ${result.autofillRows.length.toLocaleString("th-TH")} รายการ (ODX ${result.autofillOdxRows.length.toLocaleString("th-TH")} + ADP ${result.autofillAdpRows.length.toLocaleString("th-TH")} + CHA ${result.autofillChaRows.length.toLocaleString("th-TH")} + CHT ${result.autofillChtRows.length.toLocaleString("th-TH")})` : "ยังไม่มีรายการที่ต้อง Autofill");
el.referCleanupStatus.textContent = state.referCleanupEnabled
? `สำเร็จ: ลบ refer ${result.referCleanup.removedRows.toLocaleString("th-TH")} แถว และหัก CHT ${formatMoney(result.referCleanup.chtCents)} บาท รอ export`
: (result.referCleanup.previewRows.length ? `พบรายการ refer ${result.referCleanup.previewRows.length.toLocaleString("th-TH")} รายการ กดปุ่มเพื่อลบและหักยอด` : "ยังไม่พบรายการ refer ที่ต้องลบ");
el.fileTable.innerHTML = EXPECTED_FILES.map(name => {
const table = state.tables.get(name);
if (!table) {
return `<tr><td>${name}.txt</td><td class="bad">ไม่พบ</td><td>0</td><td>0</td><td>จะ export เป็นแฟ้ม header ว่างไม่ได้ กรุณาอัปโหลดให้ครบ</td></tr>`;
}
const extraRows = state.autofillEnabled
? ((name === "ODX" ? result.autofillOdxRows.length : 0)
+ (name === "ADP" ? result.autofillAdpRows.length : 0)
+ (name === "CHA" ? result.autofillChaRows.length : 0))
: 0;
const removedRows = state.referCleanupEnabled
? (name === "ORF"
? result.referCleanup.orfRows
: (name === "ADP" ? result.referCleanup.adpRows : (name === "CHA" ? result.referCleanup.chaRows : 0)))
: 0;
const filteredCount = table.filteredRows.length - removedRows + extraRows;
const notes = [];
if (extraRows) notes.push(`Autofill เพิ่ม ${extraRows.toLocaleString("th-TH")} แถว`);
if (name === "CHT" && state.autofillEnabled && result.autofillChtRows.length) notes.push(`Autofill บวก TOTAL ${formatMoney(result.autofillChtRows.length * AUTOFILL_CHARGE_CENTS)} บาท`);
if (removedRows) notes.push(`ลบ refer ${removedRows.toLocaleString("th-TH")} แถว`);
if (name === "CHT" && state.referCleanupEnabled && result.referCleanup.chtRows) notes.push(`หัก TOTAL ${formatMoney(result.referCleanup.chtCents)} บาท`);
const note = notes.length ? `พร้อม export, ${notes.join(", ")}` : (table.filteredRows.length ? "พร้อม export" : "ไม่มีแถวเข้าเงื่อนไข");
return `<tr><td>${name}.txt</td><td class="ok">พบ</td><td>${table.rows.length.toLocaleString("th-TH")}</td><td>${filteredCount.toLocaleString("th-TH")}</td><td>${note}</td></tr>`;
}).join("");
renderAutofillTable(result);
renderReferCleanupTable(result);
const preview = result.cases.slice(0, 200);
el.caseTable.innerHTML = preview.length ? preview.map((item, index) => {
return `<tr>
<td>${(index + 1).toLocaleString("th-TH")}</td>
<td>${escapeHtml(item.type)}</td>
<td>${escapeHtml(item.hn)}</td>
<td>${escapeHtml(item.an)}</td>
<td>${escapeHtml(item.seq)}</td>
<td>${escapeHtml(Array.from(item.reasons).join(", "))}</td>
<td>${formatMoney(item.chtCents
+ (state.autofillEnabled ? (result.autofillChtAdjustments.get(item.key) || 0) : 0)
- (state.referCleanupEnabled ? (item.referDeductionCents || 0) : 0))}</td>
</tr>`;
}).join("") : `<tr><td colspan="7" class="muted">ยังไม่มีเคสเข้าเงื่อนไข</td></tr>`;
if (result.cases.length > preview.length) {
el.caseTable.insertAdjacentHTML("beforeend", `<tr><td colspan="7" class="muted">แสดง 200 เคสแรกจากทั้งหมด ${result.cases.length.toLocaleString("th-TH")} เคส</td></tr>`);
}
if (result.missing.length && state.tables.size) {
showAlert(`ยังขาดแฟ้ม: ${result.missing.map(name => `${name}.txt`).join(", ")} กรุณาอัปโหลดให้ครบก่อนส่ง FDH จริง`);
} else {
clearAlert();
}
}
function renderAutofillTable(result) {
const rows = result.autofillRows || [];
if (!rows.length) {
el.autofillTable.innerHTML = `<tr><td colspan="9" class="muted">ยังไม่มีรายการที่ต้องเติม ODX Z320 หรือ ADP 4|30014</td></tr>`;
return;
}
const preview = rows.slice(0, 200);
el.autofillTable.innerHTML = preview.map((row, index) => {
const v = row.values || {};
const status = state.autofillEnabled ? "สำเร็จ" : "รอเติม";
const statusClass = state.autofillEnabled ? "ok" : "muted";
return `<tr>
<td>${(index + 1).toLocaleString("th-TH")}</td>
<td class="${statusClass}">${status}</td>
<td>${escapeHtml(v.HN)}</td>
<td>${escapeHtml(v.FILE)}</td>
<td>${escapeHtml(v.DATE)}</td>
<td>${escapeHtml(v.CODE)}</td>
<td>${escapeHtml(v.DETAIL)}</td>
<td>${escapeHtml(v.SEQ)}</td>
<td>${escapeHtml(v.TOTAL)}</td>
</tr>`;
}).join("");
if (rows.length > preview.length) {
el.autofillTable.insertAdjacentHTML("beforeend", `<tr><td colspan="9" class="muted">แสดง 200 รายการแรกจากทั้งหมด ${rows.length.toLocaleString("th-TH")} รายการ</td></tr>`);
}
}
function renderReferCleanupTable(result) {
const rows = result.referCleanup?.previewRows || [];
if (!rows.length) {
el.referCleanupTable.innerHTML = `<tr><td colspan="9" class="muted">ยังไม่มีรายการ refer ที่ต้องลบ</td></tr>`;
return;
}
if (!state.referCleanupEnabled) {
el.referCleanupTable.innerHTML = `<tr><td colspan="9" class="muted">พบ ${rows.length.toLocaleString("th-TH")} รายการ refer กดปุ่ม "ลบ refer" เพื่อแสดงผลและนำไปใช้ตอน export</td></tr>`;
return;
}
const preview = rows.slice(0, 200);
el.referCleanupTable.innerHTML = preview.map((v, index) => {
return `<tr>
<td>${(index + 1).toLocaleString("th-TH")}</td>
<td class="ok">สำเร็จ</td>
<td>${escapeHtml(v.HN)}</td>
<td>${escapeHtml(v.FILE)}</td>
<td>${escapeHtml(v.DATE)}</td>
<td>${escapeHtml(v.CODE)}</td>
<td>${escapeHtml(v.DETAIL)}</td>
<td>${escapeHtml(v.SEQ)}</td>
<td>${escapeHtml(v.TOTAL)}</td>
</tr>`;
}).join("");
if (rows.length > preview.length) {
el.referCleanupTable.insertAdjacentHTML("beforeend", `<tr><td colspan="9" class="muted">แสดง 200 รายการแรกจากทั้งหมด ${rows.length.toLocaleString("th-TH")} รายการ</td></tr>`);
}
}
function formatMoney(cents) {
return (cents / 100).toLocaleString("th-TH", {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function setBusy(busy, message) {
el.statusPill.textContent = message;
const hasPendingAutofill = state.result && state.result.autofillRows
&& state.result.autofillRows.length > 0 && !state.autofillEnabled;
el.exportButton.disabled = busy || !(state.result && state.result.cases.length) || hasPendingAutofill;
el.autofillButton.disabled = busy || !(state.result && state.result.autofillRows && state.result.autofillRows.length) || state.autofillEnabled;
el.referCleanupButton.disabled = busy || !(state.result && state.result.referCleanup && state.result.referCleanup.previewRows.length) || state.referCleanupEnabled;
}
function showAlert(message) {
el.alertBox.textContent = message;
el.alertBox.classList.add("show");
}
function clearAlert() {
el.alertBox.textContent = "";
el.alertBox.classList.remove("show");
}
function clearAll() {
state.tables = new Map();
state.result = null;
state.autofillEnabled = false;
state.referCleanupEnabled = false;
el.fileInput.value = "";
el.folderInput.value = "";
el.uploadStatus.textContent = "ยังไม่ได้อัปโหลดไฟล์";
el.statusPill.textContent = "พร้อมใช้งาน";
clearAlert();
render();
}
function buildOutputBytes(table) {
const parts = [table.headerLine];
const cleanup = state.referCleanupEnabled ? state.result?.referCleanup : null;
const autofillChtAdjustments = state.autofillEnabled ? state.result?.autofillChtAdjustments : null;
const adjustedChtKeys = new Set();
if (!(table.name === "ORF" && state.referCleanupEnabled)) {
for (const row of table.filteredRows) {
if (state.referCleanupEnabled && table.name === "ADP" && isReferAdpRow(row.values)) continue;
if (state.referCleanupEnabled && table.name === "CHA" && isReferChaRow(row.values)) continue;
if (table.name === "CHT" && (cleanup || autofillChtAdjustments)) {
const key = visitKey(row.values);
const alreadyAdjusted = adjustedChtKeys.has(key);
const deduction = alreadyAdjusted || !cleanup ? 0 : (cleanup.chaDeductions.get(key) || 0);
const addition = alreadyAdjusted || !autofillChtAdjustments ? 0 : (autofillChtAdjustments.get(key) || 0);
if (deduction || addition) {
adjustedChtKeys.add(key);
parts.push(rowLineWithOverrides(table, row, { TOTAL: centsToField(toCents(row.values.TOTAL) + addition - deduction) }));
continue;
}
}
parts.push(row.line);
}
}
if (table.name === "ODX" && state.autofillEnabled && state.result) {
for (const row of state.result.autofillOdxRows) parts.push(row.line);
}
if (table.name === "ADP" && state.autofillEnabled && state.result) {
for (const row of state.result.autofillAdpRows) parts.push(row.line);
}
if (table.name === "CHA" && state.autofillEnabled && state.result) {
for (const row of state.result.autofillChaRows) parts.push(row.line);
}
return joinBytes(parts, table.newline);
}
function joinBytes(lines, newline) {
const total = lines.reduce((sum, line) => sum + line.length, 0) + (newline.length * lines.length);
const out = new Uint8Array(total);
let offset = 0;
for (const line of lines) {
out.set(line, offset);
offset += line.length;
out.set(newline, offset);
offset += newline.length;
}
return out;
}
async function exportZip() {
if (!state.result || !state.result.cases.length) return;
if (state.result.autofillRows.length && !state.autofillEnabled) {
showAlert("พบรายการที่ยังไม่ได้ Autofill กรุณากดปุ่ม Autofill ให้เรียบร้อยก่อนส่งออก ZIP");
return;
}
if (state.result.missing.length) {
showAlert(`ยังขาดแฟ้ม: ${state.result.missing.map(name => `${name}.txt`).join(", ")} จึงยังส่งออก ZIP ไม่ได้`);
return;
}
const files = [];
for (const name of EXPECTED_FILES) {
const table = state.tables.get(name);
if (!table) continue;
files.push({ name: `${name}.txt`, data: buildOutputBytes(table) });
}
const zip = makeZip(files);
const stamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
downloadBlob(zip, `FDH_F_FULL_UCS_ADP31101_${stamp}.zip`, "application/zip");
}
function applyAutofill() {
if (!state.result || !state.result.autofillRows.length || state.autofillEnabled) return;
state.autofillEnabled = true;
analyze();
}
function applyReferCleanup() {
if (!state.result || !state.result.referCleanup.previewRows.length || state.referCleanupEnabled) return;
state.referCleanupEnabled = true;
analyze();
}
function downloadBlob(bytes, filename, type) {
const blob = new Blob([bytes], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
const crcTable = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i;
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
table[i] = c >>> 0;
}
return table;
})();
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function dosDateTime(date) {
const year = Math.max(1980, date.getFullYear());
const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
const day = ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
return { time, day };
}
function writeU16(view, offset, value) {
view.setUint16(offset, value, true);
}
function writeU32(view, offset, value) {
view.setUint32(offset, value >>> 0, true);
}
function makeZip(files) {
const encoder = new TextEncoder();
const now = dosDateTime(new Date());
const locals = [];
const centrals = [];
let offset = 0;
for (const file of files) {
const nameBytes = encoder.encode(file.name);
const data = file.data;
const crc = crc32(data);
const local = new Uint8Array(30 + nameBytes.length + data.length);
const localView = new DataView(local.buffer);
writeU32(localView, 0, 0x04034b50);
writeU16(localView, 4, 20);
writeU16(localView, 6, 0);
writeU16(localView, 8, 0);
writeU16(localView, 10, now.time);
writeU16(localView, 12, now.day);
writeU32(localView, 14, crc);
writeU32(localView, 18, data.length);
writeU32(localView, 22, data.length);
writeU16(localView, 26, nameBytes.length);
writeU16(localView, 28, 0);
local.set(nameBytes, 30);
local.set(data, 30 + nameBytes.length);
locals.push(local);
const central = new Uint8Array(46 + nameBytes.length);
const centralView = new DataView(central.buffer);
writeU32(centralView, 0, 0x02014b50);
writeU16(centralView, 4, 20);
writeU16(centralView, 6, 20);
writeU16(centralView, 8, 0);
writeU16(centralView, 10, 0);
writeU16(centralView, 12, now.time);
writeU16(centralView, 14, now.day);
writeU32(centralView, 16, crc);
writeU32(centralView, 20, data.length);
writeU32(centralView, 24, data.length);
writeU16(centralView, 28, nameBytes.length);
writeU16(centralView, 30, 0);
writeU16(centralView, 32, 0);
writeU16(centralView, 34, 0);
writeU16(centralView, 36, 0);
writeU32(centralView, 38, 0);
writeU32(centralView, 42, offset);
central.set(nameBytes, 46);
centrals.push(central);
offset += local.length;
}
const centralOffset = offset;
const centralSize = centrals.reduce((sum, item) => sum + item.length, 0);
const end = new Uint8Array(22);
const endView = new DataView(end.buffer);
writeU32(endView, 0, 0x06054b50);
writeU16(endView, 4, 0);
writeU16(endView, 6, 0);
writeU16(endView, 8, files.length);
writeU16(endView, 10, files.length);
writeU32(endView, 12, centralSize);
writeU32(endView, 16, centralOffset);
writeU16(endView, 20, 0);
return concatBytes([...locals, ...centrals, end]);
}
function concatBytes(parts) {
const total = parts.reduce((sum, part) => sum + part.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
el.fileInput.addEventListener("change", event => loadFiles(event.target.files));
el.folderInput.addEventListener("change", event => loadFiles(event.target.files));
el.clearButton.addEventListener("click", clearAll);
el.autofillButton.addEventListener("click", applyAutofill);
el.referCleanupButton.addEventListener("click", applyReferCleanup);
el.exportButton.addEventListener("click", exportZip);
for (const name of ["dragenter", "dragover"]) {
el.dropzone.addEventListener(name, event => {
event.preventDefault();
el.dropzone.classList.add("drag");
});
}
for (const name of ["dragleave", "drop"]) {
el.dropzone.addEventListener(name, event => {
event.preventDefault();
el.dropzone.classList.remove("drag");
});
}
el.dropzone.addEventListener("drop", event => loadFiles(event.dataTransfer.files));
render();
</script>
</body>
</html>