| 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/ |
Upload File : |
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CHRONICFU / SEQ Audit | HDC 43 Files</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Sarabun:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root { --green:#08724f; --green2:#10a06f; --bg:#eff7f3; --panel:#fff; --line:#d2e7dd; --text:#183328; --muted:#58766a; --blue:#0ea5e9; --orange:#f59e0b; --red:#dc2626; --soft:#f8fcfa; --shadow:0 4px 18px rgba(8,114,79,.11); }
* { box-sizing:border-box }
body { margin:0; font-family:Sarabun, sans-serif; color:var(--text); background:var(--bg); font-size:14px; }
.topbar { min-height:66px; padding:13px 22px; display:flex; justify-content:space-between; align-items:center; gap:16px; color:#fff; background:linear-gradient(120deg,#064831,#08724f 65%,#12a778); box-shadow:var(--shadow) }
h1 { margin:0; font-size:22px; line-height:1.25 }
.sub { opacity:.9; margin-top:3px; font-size:13px }
.badge { padding:8px 13px; border-radius:999px; background:rgba(255,255,255,.17); font-weight:700 }
.nav { display:flex; gap:4px; overflow-x:auto; background:#fff; border-bottom:1px solid var(--line); padding:0 18px; position:sticky; top:0; z-index:10 }
.tab { border:0; background:transparent; padding:15px 14px; font:700 14px Sarabun; color:#28493d; cursor:pointer; border-bottom:3px solid transparent; white-space:nowrap }
.tab.active { color:var(--green); border-bottom-color:var(--green) }
main { max-width:1540px; margin:0 auto; padding:22px }
.page { display:none } .page.active { display:block }
.card { background:var(--panel); border:1px solid var(--line); border-radius:8px; box-shadow:var(--shadow); padding:18px; margin-bottom:18px }
.title { display:flex; align-items:center; gap:10px; color:#063f2c; font-weight:800; font-size:18px; margin-bottom:14px }
.icon { width:34px; height:34px; display:grid; place-items:center; color:#fff; background:var(--green); border-radius:7px }
.drop { min-height:170px; border:2px dashed #9dccb8; border-radius:8px; background:#fbfffd; display:flex; flex-direction:column; justify-content:center; align-items:center; padding:24px; text-align:center; cursor:pointer }
.drop.drag,.drop:hover { border-color:var(--green); background:#e7f6f0 }
.drop strong { font-size:19px; color:var(--green) } .drop span { color:var(--muted); margin-top:7px }
.controls { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin:12px 0 }
input,select { min-height:38px; padding:8px 10px; border:1px solid #bddbce; border-radius:6px; background:#fff; font-family:Sarabun, sans-serif }
.btn { border:0; border-radius:7px; padding:9px 13px; color:#fff; background:var(--green); font:700 14px Sarabun; cursor:pointer }
.btn.outline { color:var(--green); background:#fff; border:1px solid var(--green) } .btn.warn { background:var(--orange) } .btn.blue { background:#0d6efd }
.alert { border-left:5px solid var(--blue); border-radius:8px; padding:12px 14px; background:#eaf5ff; color:#123c69; margin:12px 0 }
.alert.warn { border-left-color:var(--orange); background:#fff8e7; color:#784500 }
.alert.danger { border-left-color:var(--red); background:#fff1f1; color:#7f1d1d }
.kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:12px; margin:14px 0 }
.kpi { background:var(--soft); border:1px solid var(--line); border-top:4px solid var(--green); border-radius:8px; padding:15px }
.kpi.blue { border-top-color:var(--blue) } .kpi.yellow { border-top-color:var(--orange) } .kpi.red { border-top-color:var(--red) }
.n { font-size:28px; font-weight:900; line-height:1.1; color:#063f2c } .kpi.red .n { color:var(--red) } .kpi.yellow .n { color:#a35d00 }
.l { color:var(--muted); margin-top:5px }
.tbl { overflow:auto; border:1px solid var(--line); border-radius:8px; max-height:540px; background:#fff }
table { border-collapse:collapse; width:100%; min-width:980px }
th { position:sticky; top:0; background:var(--green); color:#fff; text-align:left; padding:10px; white-space:nowrap; z-index:1 }
td { padding:9px 10px; border-bottom:1px solid var(--line); vertical-align:top } tr:nth-child(even) td { background:#f8fcfa }
.mono { font-family:"IBM Plex Mono", Consolas, monospace }
.pill { display:inline-flex; padding:3px 8px; border-radius:999px; font-size:12px; font-weight:800; background:#e6f5ef; color:var(--green); white-space:nowrap }
.pill.red { background:#ffe6e6; color:var(--red) } .pill.yellow { background:#fff4d6; color:#9a5b00 } .pill.blue { background:#e8f3ff; color:#0b5cad }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:14px }
.empty { padding:24px; text-align:center; color:var(--muted) }
@media (max-width:900px){ main{padding:14px}.grid2{grid-template-columns:1fr}.topbar{align-items:flex-start;flex-direction:column} }
</style>
</head>
<body>
<div class="topbar">
<div>
<h1>CHRONICFU / SEQ Audit</h1>
<div class="sub">หา visit F/U ที่ HIS ไม่ generate CHRONICFU, SEQ ว่าง/ไม่ตรง SERVICE และ export จุดแก้ให้ programmer HIS</div>
</div>
<div class="badge" id="hcodeBadge">HCODE: -</div>
</div>
<div class="nav">
<button class="tab active" id="tab-upload" onclick="showPage('upload')">Upload</button>
<button class="tab" id="tab-summary" onclick="showPage('summary')">Summary</button>
<button class="tab" id="tab-missing" onclick="showPage('missing')">Missing FU</button>
<button class="tab" id="tab-seq" onclick="showPage('seq')">SEQ Problems</button>
<button class="tab" id="tab-draft" onclick="showPage('draft')">Draft CHRONICFU</button>
<button class="tab" id="tab-register" onclick="showPage('register')">CHRONIC Register</button>
<button class="tab" id="tab-export" onclick="showPage('export')">Export</button>
</div>
<main>
<section class="page active" id="page-upload">
<div class="card">
<div class="title"><span class="icon">1</span> อัปโหลด ZIP 43 แฟ้ม</div>
<div class="drop" id="drop" onclick="document.getElementById('fileInput').click()">
<strong>ลากไฟล์ F43 ZIP มาวาง หรือคลิกเพื่อเลือกไฟล์</strong>
<span>ต้องมีอย่างน้อย PERSON, SERVICE, CHRONIC, CHRONICFU; ถ้ามี LABFU/DIAGNOSIS จะช่วยชี้ว่ามี visit จริงชัดขึ้น</span>
</div>
<input type="file" id="fileInput" accept=".zip" style="display:none" onchange="handleZip(this.files[0])">
<div id="uploadInfo"></div>
</div>
<div class="grid2">
<div class="card">
<div class="title"><span class="icon">?</span> แอปนี้ตอบอะไร</div>
<div class="alert">ถ้า `SERVICE` มี visit และมี CHRONIC DM/HT active แต่ไม่มี `CHRONICFU` ที่ `PID+SEQ+DATE_SERV` ตรงกัน แอปจะจัดเป็น visit ที่ควรตรวจว่าบันทึก FU หรือระบบส่งออกขาด</div>
</div>
<div class="card">
<div class="title"><span class="icon">!</span> ข้อควรระวัง</div>
<div class="alert warn">Draft CHRONICFU เป็นไฟล์ช่วยตรวจและส่งให้ programmer/HIS ไม่ใช่หลักฐานบริการจริง ถ้าจะเติมค่าปกติ เช่น FOOT/RETINA ต้องยืนยันว่ามีการตรวจจริงในวันนั้น</div>
</div>
</div>
</section>
<section class="page" id="page-summary">
<div class="card">
<div class="title"><span class="icon">S</span> Summary</div>
<div class="controls">
<span>ปีงบ:</span><input id="fy" type="number" value="2569" style="width:100px" onchange="runAudit()">
<span>กลุ่มโรค:</span>
<select id="diseaseFilter" onchange="runAudit()">
<option value="dmht">DM + HT</option>
<option value="dm">DM เท่านั้น</option>
<option value="ht">HT เท่านั้น</option>
</select>
<button class="btn" onclick="runAudit()">คำนวณใหม่</button>
</div>
<div id="summaryKpis"></div>
<div id="summaryText"></div>
</div>
</section>
<section class="page" id="page-missing">
<div class="card">
<div class="title"><span class="icon">M</span> SERVICE มี แต่ CHRONICFU ไม่ออก</div>
<div class="controls">
<input id="missingSearch" placeholder="ค้นหา PID/CID/ชื่อ/วันที่/เหตุผล" oninput="renderMissing()">
<select id="missingLevel" onchange="renderMissing()">
<option value="all">ทุกระดับ</option>
<option value="high">High: มีหลักฐาน visit NCD/LAB/Diag</option>
<option value="medium">Medium</option>
</select>
</div>
<div id="missingTable"></div>
</div>
</section>
<section class="page" id="page-seq">
<div class="card">
<div class="title"><span class="icon">Q</span> CHRONICFU ที่ SEQ ว่างหรือไม่ตรง SERVICE</div>
<div class="controls">
<input id="seqSearch" placeholder="ค้นหา PID/CID/SEQ/เหตุผล" oninput="renderSeq()">
</div>
<div id="seqTable"></div>
</div>
</section>
<section class="page" id="page-draft">
<div class="card">
<div class="title"><span class="icon">D</span> Draft CHRONICFU สำหรับตรวจ/ส่ง HIS</div>
<div class="controls">
<span>วิธีเติมค่า:</span>
<select id="draftMode" onchange="renderDraft()">
<option value="latest_fill">ดึงค่าล่าสุด + auto-fill ช่องว่างตามเพศ</option>
<option value="latest">ดึงค่าล่าสุดของ PID ถ้ามี</option>
<option value="normal">auto-fill default ตามเพศ</option>
<option value="blank">เว้นค่าวัดผลไว้ ให้ HIS เติมจริง</option>
</select>
<span>หญิง W/H/Waist:</span><input id="defFemaleVitals" value="50/155/32" style="width:105px" onchange="renderDraft()">
<span>ชาย W/H/Waist:</span><input id="defMaleVitals" value="60/160/36" style="width:105px" onchange="renderDraft()">
<span>BP:</span><input id="defBp" value="120/80" style="width:80px" onchange="renderDraft()">
<span>FOOT default:</span><input id="defFoot" value="9" style="width:70px" onchange="renderDraft()">
<span>RETINA default:</span><input id="defRetina" value="9" style="width:70px" onchange="renderDraft()">
<span>SMOKING:</span><input id="defSmoking" value="1" style="width:70px" onchange="renderDraft()">
<span>ALCOHOL:</span><input id="defAlcohol" value="1" style="width:70px" onchange="renderDraft()">
</div>
<div class="alert warn">ถ้าเลือกค่า default ปกติ แอปจะเติมเฉพาะค่าที่ใช้เป็นรหัสสถานะ เช่น FOOT/RETINA/SMOKING/ALCOHOL ส่วนค่าน้ำหนัก ส่วนสูง รอบเอว BP จะเว้นไว้ ยกเว้นเลือกดึงค่าล่าสุดและคนไข้เคยมีค่าเดิม</div>
<div id="draftTable"></div>
</div>
</section>
<section class="page" id="page-register">
<div class="card">
<div class="title"><span class="icon">R</span> คัดกรองผู้ควรขึ้นทะเบียน CHRONIC</div>
<div class="alert warn">ใช้สำหรับตรวจสอบก่อนขึ้นทะเบียนจริง: ไม่ใช้ diagnosis ครั้งเดียวเป็นหลักฐานพอ แต่ให้คะแนนจาก 1) ยาเกี่ยวข้อง 2) LAB ติดตามซ้ำ 3) นัดต่อเนื่อง</div>
<div class="controls">
<span>ระดับหลักฐาน:</span>
<select id="regLevel" onchange="renderRegister()">
<option value="recommended">แนะนำขึ้นทะเบียน</option>
<option value="strong">หลักฐานสูง</option>
<option value="medium">กลางขึ้นไป</option>
<option value="all">ทั้งหมดที่มี diagnosis แต่ยังไม่ขึ้น</option>
</select>
<span>โรค:</span>
<select id="regDisease" onchange="renderRegister()">
<option value="all">DM + HT</option>
<option value="DM">DM</option>
<option value="HT">HT</option>
</select>
<input id="regSearch" placeholder="ค้นหา PID/CID/ชื่อ/ยา/เหตุผล" oninput="renderRegister()">
</div>
<div id="registerAudit"></div>
</div>
</section>
<section class="page" id="page-export">
<div class="card">
<div class="title"><span class="icon">E</span> Export</div>
<div class="controls">
<button class="btn blue" onclick="exportWorkbook()">Export Excel ให้ HIS Programmer</button>
<button class="btn" onclick="exportFixed43Zip()">Export 43 ZIP เติม CHRONICFU/CHRONIC</button>
<label><input type="checkbox" id="includeChronicAdds" onchange="renderExport()"> เพิ่ม CHRONIC เฉพาะ candidate หลักฐานสูง</label>
<button class="btn outline" onclick="copyBrief()">Copy สรุปปัญหา</button>
</div>
<div id="exportInfo"></div>
</div>
</section>
</main>
<script>
const state = { files:{}, hcode:'', active:'upload', audit:{ missing:[], seqProblems:[], incomplete:[], drafts:[], register:[], chronicPatients:0, serviceVisits:0, matchedFu:0 } };
const cfFields = ['WEIGHT','HEIGHT','WAIST_CM','SBP','DBP','FOOT','RETINA','SMOKING','ALCOHOL','PROVIDER','CHRONICFUPLACE'];
const normalFields = ['WEIGHT','HEIGHT','WAIST_CM','SBP','DBP','FOOT','RETINA','SMOKING','ALCOHOL'];
const chronicDrugKeywords = {
DM: ['metformin','glipizide','gliclazide','glimepiride','insulin','pioglitazone','dapagliflozin','empagliflozin','sitagliptin','linagliptin','vildagliptin','alogliptin'],
HT: ['amlodipine','manidipine','nifedipine','enalapril','losartan','valsartan','telmisartan','atenolol','metoprolol','propranolol','carvedilol','hctz','hydrochlorothiazide','diltiazem','verapamil','doxazosin','hydralazine']
};
const chronicLabCodes = {
DM: new Set(['0531601','0531001','0531002','0531101','0531102','0541402','0541602','0546602','0581902','0583001']),
HT: new Set(['0581902','0621201','0541402','0541602','0546602','0583001'])
};
function $(id){ return document.getElementById(id); }
function compact(v){ return String(v ?? '').trim(); }
function esc(v){ return String(v ?? '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
function fmt(n){ return Number(n || 0).toLocaleString('th-TH'); }
function normName(n){ return n.split('/').pop().toLowerCase(); }
function ymd(v){ return compact(v).slice(0,8); }
function isDate8(v){ return /^[12]\d{7}$/.test(compact(v)); }
function isDm(code){ return /^E1[0-4]/i.test(compact(code)); }
function isHt(code){ return /^I1[0-5]/i.test(compact(code)); }
function isNcd(code){ return isDm(code) || isHt(code); }
function today8(){ const d = new Date(); return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`; }
function currentThaiFiscalYear(){ const d = new Date(); const y = d.getFullYear() + 543; return d.getMonth()+1 >= 10 ? y+1 : y; }
function fiscalRange(fy){ fy = Number(fy || currentThaiFiscalYear()); return { start:String(fy-544)+'1001', end:String(fy-543)+'0930' }; }
function parseTxt(text){
text = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lines = text.split('\n').filter(x => x.trim() !== '');
if (!lines.length) return { header:[], rows:[] };
return { header:lines[0].split('|').map(x => x.trim().toUpperCase()), rows:lines.slice(1).map(line => line.split('|')) };
}
function col(file, name){ return state.files[file]?.header.indexOf(name.toUpperCase()) ?? -1; }
function val(file, row, name){ const i = col(file, name); return i >= 0 ? compact(row[i]) : ''; }
function personMap(){
const m = new Map(), f = state.files['person.txt'];
if (!f) return m;
f.rows.forEach((row,i) => {
const pid = val('person.txt', row, 'PID');
if (pid) m.set(pid, { row, rowNum:i+2, pid, cid:val('person.txt', row, 'CID'), name:`${val('person.txt', row, 'NAME') || val('person.txt', row, 'FNAME')} ${val('person.txt', row, 'LNAME')}`.trim(), sex:val('person.txt', row, 'SEX'), typearea:val('person.txt', row, 'TYPEAREA'), discharge:val('person.txt', row, 'DISCHARGE'), nation:val('person.txt', row, 'NATION') });
});
return m;
}
function showPage(page){
state.active = page;
document.querySelectorAll('.page').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
$('page-' + page).classList.add('active');
$('tab-' + page).classList.add('active');
renderActive();
}
const drop = $('drop');
drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('drag'); });
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
drop.addEventListener('drop', e => { e.preventDefault(); drop.classList.remove('drag'); if (e.dataTransfer.files[0]) handleZip(e.dataTransfer.files[0]); });
async function handleZip(file){
if (!file) return;
$('uploadInfo').innerHTML = '<div class="alert">กำลังอ่าน ZIP และตรวจ CHRONICFU/SEQ...</div>';
const zip = await JSZip.loadAsync(await file.arrayBuffer());
state.files = {};
for (const entry of Object.values(zip.files).filter(f => !f.dir && f.name.toLowerCase().endsWith('.txt'))) {
state.files[normName(entry.name)] = parseTxt(await entry.async('string'));
}
const pf = state.files['person.txt'];
state.hcode = pf?.rows?.length ? val('person.txt', pf.rows[0], 'HOSPCODE') : '';
$('hcodeBadge').textContent = 'HCODE: ' + (state.hcode || '-');
if ($('fy')) $('fy').value = $('fy').value || currentThaiFiscalYear();
runAudit();
$('uploadInfo').innerHTML = `<div class="alert">อ่านไฟล์สำเร็จ ${fmt(Object.keys(state.files).length)} แฟ้ม, PERSON ${fmt(pf?.rows.length || 0)} records</div>`;
showPage('summary');
}
function activeChronicPatients(){
const f = state.files['chronic.txt'];
if (!f) return new Map();
const mode = $('diseaseFilter')?.value || 'dmht';
const out = new Map();
f.rows.forEach((row,i) => {
const pid = val('chronic.txt', row, 'PID');
const code = val('chronic.txt', row, 'CHRONIC');
const typedisch = val('chronic.txt', row, 'TYPEDISCH');
if (!pid || typedisch !== '03') return;
const dm = isDm(code), ht = isHt(code);
if ((mode === 'dm' && !dm) || (mode === 'ht' && !ht) || (mode === 'dmht' && !dm && !ht)) return;
if (!out.has(pid)) out.set(pid, { pid, diseases:new Set(), rows:[] });
if (dm) out.get(pid).diseases.add('DM');
if (ht) out.get(pid).diseases.add('HT');
out.get(pid).rows.push({ rowNum:i+2, code, date:val('chronic.txt', row, 'DATE_DIAG') });
});
return out;
}
function chronicGroup(code){
if (isDm(code)) return 'DM';
if (isHt(code)) return 'HT';
return '';
}
function existingChronicGroupKeys(){
const keys = new Set();
const f = state.files['chronic.txt'];
if (!f) return keys;
f.rows.forEach(row => {
const pid = val('chronic.txt', row, 'PID');
const group = chronicGroup(val('chronic.txt', row, 'CHRONIC'));
if (pid && group) keys.add(`${pid}|${group}`);
});
return keys;
}
function chronicCandidatesFromDiagnosis(){
const people = personMap();
const existing = existingChronicGroupKeys();
const latest = new Map();
const fy = Number($('fy')?.value || currentThaiFiscalYear());
const { start, end } = fiscalRange(fy);
const collect = (file, codeField, dateField) => {
const f = state.files[file];
if (!f) return;
f.rows.forEach((row,i) => {
const pid = val(file, row, 'PID');
const code = val(file, row, codeField);
const group = chronicGroup(code);
if (!pid || !group || existing.has(`${pid}|${group}`)) return;
const date = ymd(val(file, row, dateField)) || today8();
if (date < start || date > end) return;
const key = `${pid}|${group}`;
const cur = latest.get(key);
if (!cur || date > cur.date) {
const p = people.get(pid);
latest.set(key, {
key, pid, cid:p?.cid || val(file, row, 'CID'), group, chronic:code,
date, source:file, sourceRow:i+2
});
}
});
};
collect('diagnosis_opd.txt', 'DIAGCODE', 'DATE_SERV');
collect('diagnosis_ipd.txt', 'DIAGCODE', 'DATETIME_ADMIT');
return [...latest.values()];
}
function diagnosisEvidenceByPidGroup(){
const fy = Number($('fy')?.value || currentThaiFiscalYear());
const { start, end } = fiscalRange(fy);
const map = new Map();
const add = (file, row, rowNum, code, date) => {
const pid = val(file, row, 'PID');
const group = chronicGroup(code);
if (!pid || !group || date < start || date > end) return;
const key = `${pid}|${group}`;
if (!map.has(key)) map.set(key, { count:0, dates:new Set(), codes:new Set(), latest:'', rows:[] });
const x = map.get(key);
x.count++;
x.dates.add(date);
x.codes.add(code);
if (date > x.latest) x.latest = date;
x.rows.push(`${file} row ${rowNum} ${date} ${code}`);
};
['diagnosis_opd.txt','diagnosis_ipd.txt'].forEach(file => {
const f = state.files[file];
if (!f) return;
f.rows.forEach((row,i) => {
const code = val(file, row, file === 'diagnosis_ipd.txt' ? 'DIAGCODE' : 'DIAGCODE');
const date = ymd(val(file, row, file === 'diagnosis_ipd.txt' ? 'DATETIME_ADMIT' : 'DATE_SERV'));
add(file, row, i+2, code, date);
});
});
return map;
}
function drugEvidence(pid, group){
const fy = Number($('fy')?.value || currentThaiFiscalYear());
const { start, end } = fiscalRange(fy);
const kws = chronicDrugKeywords[group] || [];
const out = { count:0, dates:new Set(), names:new Set(), rows:[] };
const scan = (file, dateField) => {
const f = state.files[file];
if (!f) return;
f.rows.forEach((row,i) => {
if (val(file, row, 'PID') !== pid) return;
const date = ymd(val(file, row, dateField));
if (date < start || date > end) return;
const name = val(file, row, 'DNAME').toLowerCase();
const did = val(file, row, 'DIDSTD');
if (!kws.some(k => name.includes(k))) return;
out.count++;
out.dates.add(date);
out.names.add(val(file, row, 'DNAME') || did);
out.rows.push(`${file} row ${i+2} ${date} ${val(file, row, 'DNAME') || did}`);
});
};
scan('drug_opd.txt', 'DATE_SERV');
scan('drug_ipd.txt', 'DATETIME_ADMIT');
return out;
}
function labFollowEvidence(pid, group){
const fy = Number($('fy')?.value || currentThaiFiscalYear());
const { start, end } = fiscalRange(fy);
const f = state.files['labfu.txt'];
const codes = chronicLabCodes[group] || new Set();
const out = { count:0, dates:new Set(), tests:new Set(), rows:[] };
if (!f) return out;
f.rows.forEach((row,i) => {
if (val('labfu.txt', row, 'PID') !== pid) return;
const date = val('labfu.txt', row, 'DATE_SERV');
const test = val('labfu.txt', row, 'LABTEST');
if (date < start || date > end || !codes.has(test)) return;
out.count++;
out.dates.add(date);
out.tests.add(test);
out.rows.push(`labfu row ${i+2} ${date} ${test}=${val('labfu.txt', row, 'LABRESULT')}`);
});
return out;
}
function appointmentEvidence(pid, group){
const fy = Number($('fy')?.value || currentThaiFiscalYear());
const { start, end } = fiscalRange(fy);
const f = state.files['appointment.txt'];
const out = { count:0, dates:new Set(), diagDates:new Set(), rows:[] };
if (!f) return out;
f.rows.forEach((row,i) => {
if (val('appointment.txt', row, 'PID') !== pid) return;
const apdate = val('appointment.txt', row, 'APDATE');
const dateServ = val('appointment.txt', row, 'DATE_SERV');
const date = apdate || dateServ;
if (date < start || date > end) return;
const apdiag = val('appointment.txt', row, 'APDIAG');
const apGroup = chronicGroup(apdiag);
if (apGroup && apGroup !== group) return;
out.count++;
out.dates.add(date);
if (apGroup === group) out.diagDates.add(date);
out.rows.push(`appointment row ${i+2} ${date} ${apdiag || ''}`);
});
return out;
}
function buildRegisterCandidates(){
if (!hasData()) return [];
const people = personMap();
const diagMap = diagnosisEvidenceByPidGroup();
return chronicCandidatesFromDiagnosis().map(c => {
const p = people.get(c.pid);
const diag = diagMap.get(c.key) || { count:1, dates:new Set([c.date]), codes:new Set([c.chronic]), latest:c.date, rows:[] };
const drug = drugEvidence(c.pid, c.group);
const lab = labFollowEvidence(c.pid, c.group);
const appt = appointmentEvidence(c.pid, c.group);
let score = 0;
score += Math.min(10, drug.count * 3) + (drug.dates.size >= 2 ? 4 : drug.dates.size ? 2 : 0);
score += lab.dates.size >= 2 ? 4 : lab.dates.size === 1 ? 2 : 0;
score += appt.dates.size >= 2 ? 3 : appt.dates.size === 1 ? 1 : 0;
score += diag.dates.size >= 2 ? 2 : 0;
let level = 'ต่ำ';
if (drug.count >= 2 || (drug.count >= 1 && (lab.dates.size >= 1 || appt.dates.size >= 1)) || score >= 8) level = 'สูง';
else if (drug.count >= 1 || lab.dates.size >= 2 || appt.dates.size >= 2 || score >= 4) level = 'กลาง';
const recommend = level === 'สูง' ? 'แนะนำขึ้นทะเบียน' : level === 'กลาง' ? 'ส่งเจ้าหน้าที่ตรวจสอบ' : 'ยังไม่ควรขึ้นจากหลักฐานนี้อย่างเดียว';
return {
LEVEL:level, RECOMMEND:recommend, SCORE:score, GROUP:c.group, PID:c.pid, CID:p?.cid || c.cid || '', NAME:p?.name || '', SEX:p?.sex || '',
TYPEAREA:p?.typearea || '', DISCHARGE:p?.discharge || '', NATION:p?.nation || '',
CHRONIC_SUGGEST:c.chronic, DATE_DIAG:c.date, DIAG_COUNT:diag.count, DIAG_DATES:diag.dates.size,
DRUG_COUNT:drug.count, DRUG_DATES:drug.dates.size, DRUG_NAMES:[...drug.names].slice(0,5).join(' | '),
LAB_COUNT:lab.count, LAB_DATES:lab.dates.size, LAB_TESTS:[...lab.tests].join(', '),
APPOINT_COUNT:appt.count, APPOINT_DATES:appt.dates.size,
EVIDENCE:[...drug.rows.slice(0,3), ...lab.rows.slice(0,2), ...appt.rows.slice(0,2), ...diag.rows.slice(0,2)].join(' || '),
SOURCE_DIAG:`${c.source} row ${c.sourceRow}`
};
}).sort((a,b) => b.SCORE - a.SCORE || a.GROUP.localeCompare(b.GROUP));
}
function serviceRowsByPid(fy){
const f = state.files['service.txt'];
const out = new Map();
if (!f) return out;
const { start, end } = fiscalRange(fy);
f.rows.forEach((row,i) => {
const pid = val('service.txt', row, 'PID');
const date = val('service.txt', row, 'DATE_SERV');
if (!pid || date < start || date > end) return;
if (!out.has(pid)) out.set(pid, []);
out.get(pid).push({ file:'service.txt', row, rowNum:i+2, pid, cid:val('service.txt', row, 'CID'), seq:val('service.txt', row, 'SEQ'), date, provider:val('service.txt', row, 'PROVIDER'), clinic:val('service.txt', row, 'CLINIC'), chiefcomp:val('service.txt', row, 'CHIEFCOMP') });
});
return out;
}
function chronicFuRowsByPid(fy){
const f = state.files['chronicfu.txt'];
const out = new Map();
if (!f) return out;
const { start, end } = fiscalRange(fy);
f.rows.forEach((row,i) => {
const pid = val('chronicfu.txt', row, 'PID');
const date = val('chronicfu.txt', row, 'DATE_SERV');
if (!pid || date < start || date > end) return;
if (!out.has(pid)) out.set(pid, []);
out.get(pid).push({ file:'chronicfu.txt', row, rowNum:i+2, pid, cid:val('chronicfu.txt', row, 'CID'), seq:val('chronicfu.txt', row, 'SEQ'), date });
});
return out;
}
function relatedEvidence(pid, seq, date){
const pieces = [];
const dopd = state.files['diagnosis_opd.txt'];
if (dopd) {
const rows = dopd.rows.filter(r => val('diagnosis_opd.txt', r, 'PID') === pid && val('diagnosis_opd.txt', r, 'DATE_SERV') === date && (!seq || val('diagnosis_opd.txt', r, 'SEQ') === seq));
const ncd = rows.map(r => val('diagnosis_opd.txt', r, 'DIAGCODE')).filter(isNcd);
if (ncd.length) pieces.push('DIAG_NCD=' + [...new Set(ncd)].join(','));
}
const lab = state.files['labfu.txt'];
if (lab) {
const rows = lab.rows.filter(r => val('labfu.txt', r, 'PID') === pid && val('labfu.txt', r, 'DATE_SERV') === date && (!seq || val('labfu.txt', r, 'SEQ') === seq));
const codes = rows.map(r => val('labfu.txt', r, 'LABTEST')).filter(Boolean);
if (codes.length) pieces.push('LABFU=' + [...new Set(codes)].slice(0,8).join(','));
}
return pieces.join(' | ');
}
function latestFuBefore(pid, date){
const f = state.files['chronicfu.txt'];
if (!f) return null;
let best = null;
f.rows.forEach((row,i) => {
if (val('chronicfu.txt', row, 'PID') !== pid) return;
const d = val('chronicfu.txt', row, 'DATE_SERV');
if (!isDate8(d) || d > date) return;
if (!best || d > best.date) best = { row, rowNum:i+2, date:d };
});
return best;
}
function runAudit(){
if (!Object.keys(state.files).length) return;
const fy = Number($('fy')?.value || currentThaiFiscalYear());
const people = personMap();
const chronic = activeChronicPatients();
const services = serviceRowsByPid(fy);
const fus = chronicFuRowsByPid(fy);
const missing = [];
let serviceVisits = 0, matchedFu = 0;
chronic.forEach((info,pid) => {
const person = people.get(pid);
const serviceRows = services.get(pid) || [];
const fuRows = fus.get(pid) || [];
const fuKeys = new Set(fuRows.map(x => `${x.seq}|${x.date}`));
serviceRows.forEach(s => {
serviceVisits++;
const exact = s.seq && fuKeys.has(`${s.seq}|${s.date}`);
const sameDateFu = fuRows.some(f => f.date === s.date);
if (exact) { matchedFu++; return; }
const evidence = relatedEvidence(pid, s.seq, s.date);
const level = evidence || sameDateFu ? 'high' : 'medium';
missing.push({
level, pid, cid:person?.cid || s.cid || '', name:person?.name || '', disease:[...info.diseases].join('/'),
typearea:person?.typearea || '', discharge:person?.discharge || '', serviceRow:s.rowNum, seq:s.seq, date:s.date,
clinic:s.clinic, provider:s.provider, sameDateFu:sameDateFu ? 'Y' : 'N', evidence,
reason: sameDateFu ? 'มี CHRONICFU วันเดียวกัน แต่ SEQ ไม่ตรง SERVICE' : 'มี SERVICE แต่ไม่มี CHRONICFU ที่ PID+SEQ+DATE_SERV ตรงกัน',
hisFix: s.seq ? 'ตรวจ mapping หน้าจอ F/U ให้ generate CHRONICFU ด้วย SEQ นี้' : 'SERVICE.SEQ ว่าง ต้องให้ HIS generate SEQ ก่อนสร้าง CHRONICFU'
});
});
});
const seqProblems = [];
const incomplete = [];
const serviceKey = new Set();
services.forEach(rows => rows.forEach(s => serviceKey.add(`${s.pid}|${s.seq}|${s.date}`)));
fus.forEach(rows => rows.forEach(fu => {
if (!fu.seq) seqProblems.push(problemFromFu(fu, 'CHRONICFU.SEQ ว่าง', 'ให้ HIS ผูก CHRONICFU กับ visit/SERVICE.SEQ ที่ถูกต้อง'));
else if (!serviceKey.has(`${fu.pid}|${fu.seq}|${fu.date}`)) seqProblems.push(problemFromFu(fu, 'CHRONICFU.SEQ ไม่พบใน SERVICE วันเดียวกัน', 'ตรวจว่า SEQ/DATE_SERV/PID ตรงกับ SERVICE หรือไม่'));
if (chronic.has(fu.pid)) {
const missingFields = normalFields.filter(field => !val('chronicfu.txt', fu.row, field));
if (missingFields.length) {
const p = people.get(fu.pid);
incomplete.push({
level: missingFields.some(f => ['WEIGHT','HEIGHT','WAIST_CM','SBP','DBP'].includes(f)) ? 'high' : 'medium',
rowNum:fu.rowNum, pid:fu.pid, cid:p?.cid || fu.cid || '', name:p?.name || '', sex:p?.sex || '',
seq:fu.seq, date:fu.date, missingFields:missingFields.join(', '),
reason:`CHRONICFU มีแล้ว แต่ช่องสำคัญว่าง: ${missingFields.join(', ')}`,
hisFix:'เติมค่าที่ขาดจาก vital sign/หน้าจอ F/U หรือให้ HIS map field เหล่านี้ออก CHRONICFU'
});
}
}
}));
state.audit = { missing, seqProblems, incomplete, drafts:[], chronicPatients:chronic.size, serviceVisits, matchedFu };
state.audit.drafts = buildDraftRows();
state.audit.register = buildRegisterCandidates();
renderActive();
}
function problemFromFu(fu, reason, hisFix){
const people = personMap(), p = people.get(fu.pid);
return { level:'high', rowNum:fu.rowNum, pid:fu.pid, cid:p?.cid || fu.cid || '', name:p?.name || '', seq:fu.seq, date:fu.date, reason, hisFix };
}
function draftHeader(){
const f = state.files['chronicfu.txt'];
const base = f?.header?.length ? f.header : [];
const required = ['HOSPCODE','PID','SEQ','DATE_SERV','WEIGHT','HEIGHT','WAIST_CM','SBP','DBP','FOOT','RETINA','SMOKING','ALCOHOL','PROVIDER','D_UPDATE','CHRONICFUPLACE','CID'];
return [...new Set([...base, ...required])];
}
function parseTriple(id, fallback){
const raw = ($ ? ($(id)?.value || '') : '').split('/').map(x => x.trim());
return {
weight: raw[0] || fallback.weight,
height: raw[1] || fallback.height,
waist: raw[2] || fallback.waist
};
}
function parseBp(){
const raw = ($('defBp')?.value || '120/80').split('/').map(x => x.trim());
return { sbp: raw[0] || '120', dbp: raw[1] || '80' };
}
function normalDefaultsForSex(sex){
const female = parseTriple('defFemaleVitals', { weight:'50', height:'155', waist:'32' });
const male = parseTriple('defMaleVitals', { weight:'60', height:'160', waist:'36' });
const vitals = sex === '2' ? female : male;
const bp = parseBp();
return {
WEIGHT:vitals.weight, HEIGHT:vitals.height, WAIST_CM:vitals.waist,
SBP:bp.sbp, DBP:bp.dbp,
FOOT:$('defFoot')?.value || '9',
RETINA:$('defRetina')?.value || '9',
SMOKING:$('defSmoking')?.value || '1',
ALCOHOL:$('defAlcohol')?.value || '1'
};
}
function fillMissingNormal(obj, sex, header){
const defaults = normalDefaultsForSex(sex);
const filled = [];
Object.entries(defaults).forEach(([field, value]) => {
if (!compact(obj[field]) && compact(value)) {
obj[field] = value;
filled.push(`${field}=${value}`);
}
});
return filled;
}
function buildDraftRows(){
const mode = $('draftMode')?.value || 'latest_fill';
const header = draftHeader();
const people = personMap();
const missingDrafts = state.audit.missing.map(x => {
const latest = latestFuBefore(x.pid, x.date);
const person = people.get(x.pid);
const obj = {};
header.forEach(h => obj[h] = '');
obj.HOSPCODE = state.hcode;
obj.PID = x.pid;
obj.SEQ = x.seq;
obj.DATE_SERV = x.date;
obj.CID = x.cid;
obj.PROVIDER = x.provider || (latest ? val('chronicfu.txt', latest.row, 'PROVIDER') : '');
obj.D_UPDATE = today8();
obj.CHRONICFUPLACE = state.hcode;
obj.SEX = person?.sex || '';
let filled = [];
if ((mode === 'latest' || mode === 'latest_fill') && latest) {
cfFields.forEach(f => { if (header.includes(f)) obj[f] = val('chronicfu.txt', latest.row, f); });
obj.DRAFT_SOURCE = `latest CHRONICFU row ${latest.rowNum} date ${latest.date}`;
if (mode === 'latest_fill') {
filled = fillMissingNormal(obj, person?.sex || '', header);
obj.DRAFT_SOURCE += filled.length ? ` + autofill missing (${filled.join(', ')})` : ' + no missing normal fields';
}
} else if (mode === 'latest_fill' && !latest) {
filled = fillMissingNormal(obj, person?.sex || '', header);
obj.DRAFT_SOURCE = `no latest CHRONICFU; autofill default by sex ${person?.sex === '2' ? 'female' : 'male/unknown'} (${filled.join(', ')})`;
} else if (mode === 'normal') {
filled = fillMissingNormal(obj, person?.sex || '', header);
obj.DRAFT_SOURCE = `default by sex ${person?.sex === '2' ? 'female' : 'male/unknown'} (${filled.join(', ')})`;
} else {
obj.DRAFT_SOURCE = 'blank measured fields; HIS must fill from real visit';
}
obj.AUTOFILL_FIELDS = filled.join(', ');
obj.AUDIT_REASON = x.reason;
obj.HIS_FIX = x.hisFix;
return obj;
});
const incompleteDrafts = state.audit.incomplete.map(x => {
const person = people.get(x.pid);
const source = state.files['chronicfu.txt']?.rows[x.rowNum - 2];
const obj = {};
header.forEach(h => obj[h] = source ? val('chronicfu.txt', source, h) : '');
obj.CID = obj.CID || x.cid;
obj.SEX = person?.sex || x.sex || '';
const filled = mode === 'blank' ? [] : fillMissingNormal(obj, person?.sex || x.sex || '', header);
obj.DRAFT_SOURCE = source
? `existing CHRONICFU row ${x.rowNum}; autofill missing only (${filled.join(', ') || 'none'})`
: `existing CHRONICFU row ${x.rowNum} not found`;
obj.AUTOFILL_FIELDS = filled.join(', ');
obj.AUDIT_REASON = x.reason;
obj.HIS_FIX = x.hisFix;
return obj;
});
return [...missingDrafts, ...incompleteDrafts];
}
function renderActive(){
if (state.active === 'summary') renderSummary();
if (state.active === 'missing') renderMissing();
if (state.active === 'seq') renderSeq();
if (state.active === 'draft') renderDraft();
if (state.active === 'register') renderRegister();
if (state.active === 'export') renderExport();
}
function needUpload(){ return '<div class="empty">กรุณาอัปโหลด ZIP 43 แฟ้มก่อน</div>'; }
function hasData(){ return Object.keys(state.files).length > 0; }
function match(row,q){ if (!q) return true; return Object.values(row).some(v => String(v ?? '').toLowerCase().includes(q)); }
function table(rows, cols, empty='ไม่พบรายการ'){
if (!rows.length) return `<div class="empty">${empty}</div>`;
return `<div class="tbl"><table><thead><tr>${cols.map(c => `<th>${esc(c.label)}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr>${cols.map(c => `<td>${c.html ? c.html(r) : esc(r[c.key])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
}
function renderSummary(){
if (!hasData()) { $('summaryKpis').innerHTML = needUpload(); $('summaryText').innerHTML = ''; return; }
const high = state.audit.missing.filter(x => x.level === 'high').length + state.audit.seqProblems.length + state.audit.incomplete.filter(x => x.level === 'high').length;
$('summaryKpis').innerHTML = `<div class="kpis">
<div class="kpi"><div class="n">${fmt(state.audit.chronicPatients)}</div><div class="l">CHRONIC active ในกลุ่มที่เลือก</div></div>
<div class="kpi blue"><div class="n">${fmt(state.audit.serviceVisits)}</div><div class="l">SERVICE visit ในปีงบ</div></div>
<div class="kpi"><div class="n">${fmt(state.audit.matchedFu)}</div><div class="l">มี CHRONICFU ตรง SEQ</div></div>
<div class="kpi red"><div class="n">${fmt(state.audit.missing.length)}</div><div class="l">SERVICE มี แต่ FU ไม่ตรง/ไม่ออก</div></div>
<div class="kpi red"><div class="n">${fmt(state.audit.seqProblems.length)}</div><div class="l">CHRONICFU SEQ ผิด</div></div>
<div class="kpi yellow"><div class="n">${fmt(state.audit.incomplete.length)}</div><div class="l">CHRONICFU มีแต่ vital/normal field ว่าง</div></div>
<div class="kpi yellow"><div class="n">${fmt(state.audit.drafts.length)}</div><div class="l">Draft rows ที่ export ได้</div></div>
</div>`;
$('summaryText').innerHTML = high ? `<div class="alert danger">พบจุดที่ควรส่ง HIS programmer ตรวจ ${fmt(high)} รายการ โดยเฉพาะ visit ที่มี LAB/DIAG แต่ไม่เกิด CHRONICFU</div>` : '<div class="alert">ไม่พบปัญหา CHRONICFU/SEQ หลักจากเงื่อนไขนี้</div>';
}
function renderMissing(){
if (!hasData()) { $('missingTable').innerHTML = needUpload(); return; }
const q = compact($('missingSearch').value).toLowerCase();
const lv = $('missingLevel').value;
const rows = state.audit.missing.filter(r => (lv === 'all' || r.level === lv) && match(r,q)).slice(0,1800);
$('missingTable').innerHTML = table(rows, [
{label:'ระดับ', html:r => `<span class="pill ${r.level === 'high' ? 'red' : 'yellow'}">${esc(r.level)}</span>`},
{label:'PID', html:r => `<span class="mono">${esc(r.pid)}</span>`}, {label:'CID', html:r => `<span class="mono">${esc(r.cid)}</span>`}, {label:'ชื่อ', key:'name'},
{label:'โรค', key:'disease'}, {label:'TYPEAREA', html:r => `<span class="mono">${esc(r.typearea)}</span>`}, {label:'SERVICE row', html:r => `<span class="mono">${esc(r.serviceRow)}</span>`},
{label:'SEQ', html:r => `<span class="mono">${esc(r.seq || 'ว่าง')}</span>`}, {label:'DATE_SERV', html:r => `<span class="mono">${esc(r.date)}</span>`},
{label:'Clinic', key:'clinic'}, {label:'Evidence', key:'evidence'}, {label:'เหตุผล', key:'reason'}, {label:'จุดให้ HIS แก้', key:'hisFix'}
]);
}
function renderSeq(){
if (!hasData()) { $('seqTable').innerHTML = needUpload(); return; }
const q = compact($('seqSearch').value).toLowerCase();
const rows = state.audit.seqProblems.filter(r => match(r,q)).slice(0,1800);
const incompleteRows = state.audit.incomplete.filter(r => match(r,q)).slice(0,1800);
$('seqTable').innerHTML = `
<div class="alert warn">ส่วนบนคือปัญหา SEQ; ส่วนล่างคือ CHRONICFU ที่มีแถวแล้วแต่ค่าน้ำหนัก/ส่วนสูง/รอบเอว/BP หรือค่า normal อื่นว่าง</div>
${table(rows, [
{label:'ระดับ', html:r => '<span class="pill red">high</span>'}, {label:'CHRONICFU row', html:r => `<span class="mono">${esc(r.rowNum)}</span>`},
{label:'PID', html:r => `<span class="mono">${esc(r.pid)}</span>`}, {label:'CID', html:r => `<span class="mono">${esc(r.cid)}</span>`}, {label:'ชื่อ', key:'name'},
{label:'SEQ', html:r => `<span class="mono">${esc(r.seq || 'ว่าง')}</span>`}, {label:'DATE_SERV', html:r => `<span class="mono">${esc(r.date)}</span>`}, {label:'เหตุผล', key:'reason'}, {label:'จุดให้ HIS แก้', key:'hisFix'}
])}
<div style="height:14px"></div>
${table(incompleteRows, [
{label:'ระดับ', html:r => `<span class="pill ${r.level === 'high' ? 'red' : 'yellow'}">${esc(r.level)}</span>`},
{label:'CHRONICFU row', html:r => `<span class="mono">${esc(r.rowNum)}</span>`},
{label:'PID', html:r => `<span class="mono">${esc(r.pid)}</span>`}, {label:'CID', html:r => `<span class="mono">${esc(r.cid)}</span>`}, {label:'SEX', html:r => `<span class="mono">${esc(r.sex || '-')}</span>`}, {label:'ชื่อ', key:'name'},
{label:'SEQ', html:r => `<span class="mono">${esc(r.seq || 'ว่าง')}</span>`}, {label:'DATE_SERV', html:r => `<span class="mono">${esc(r.date)}</span>`}, {label:'ช่องที่ขาด', key:'missingFields'}, {label:'จุดให้ HIS แก้', key:'hisFix'}
], 'ไม่พบ CHRONICFU ที่ค่าว่างตามเงื่อนไข')}`;
}
function renderDraft(){
if (!hasData()) { $('draftTable').innerHTML = needUpload(); return; }
state.audit.drafts = buildDraftRows();
$('draftTable').innerHTML = table(state.audit.drafts.slice(0,1200), [
{label:'PID', html:r => `<span class="mono">${esc(r.PID)}</span>`}, {label:'CID', html:r => `<span class="mono">${esc(r.CID)}</span>`}, {label:'SEX', html:r => `<span class="mono">${esc(r.SEX || '-')}</span>`}, {label:'SEQ', html:r => `<span class="mono">${esc(r.SEQ || 'ว่าง')}</span>`},
{label:'DATE_SERV', html:r => `<span class="mono">${esc(r.DATE_SERV)}</span>`}, {label:'WEIGHT', key:'WEIGHT'}, {label:'HEIGHT', key:'HEIGHT'}, {label:'WAIST', key:'WAIST_CM'}, {label:'SBP', key:'SBP'}, {label:'DBP', key:'DBP'},
{label:'FOOT', key:'FOOT'}, {label:'RETINA', key:'RETINA'}, {label:'SMOKING', key:'SMOKING'}, {label:'ALCOHOL', key:'ALCOHOL'}, {label:'PROVIDER', key:'PROVIDER'}, {label:'Autofill', key:'AUTOFILL_FIELDS'}, {label:'Draft source', key:'DRAFT_SOURCE'}, {label:'HIS fix', key:'HIS_FIX'}
]);
}
function registerFilterRows(){
const q = compact($('regSearch')?.value || '').toLowerCase();
const level = $('regLevel')?.value || 'recommended';
const disease = $('regDisease')?.value || 'all';
return (state.audit.register || []).filter(r => {
if (disease !== 'all' && r.GROUP !== disease) return false;
if (level === 'recommended' && r.RECOMMEND !== 'แนะนำขึ้นทะเบียน') return false;
if (level === 'strong' && r.LEVEL !== 'สูง') return false;
if (level === 'medium' && !['สูง','กลาง'].includes(r.LEVEL)) return false;
return match(r, q);
});
}
function renderRegister(){
if (!hasData()) { $('registerAudit').innerHTML = needUpload(); return; }
if (!state.audit.register?.length) state.audit.register = buildRegisterCandidates();
const rows = registerFilterRows();
const high = rows.filter(x => x.LEVEL === 'สูง').length;
const med = rows.filter(x => x.LEVEL === 'กลาง').length;
const low = rows.filter(x => x.LEVEL === 'ต่ำ').length;
$('registerAudit').innerHTML = `
<div class="kpis">
<div class="kpi red"><div class="n">${fmt(high)}</div><div class="l">หลักฐานสูง</div></div>
<div class="kpi yellow"><div class="n">${fmt(med)}</div><div class="l">หลักฐานกลาง</div></div>
<div class="kpi"><div class="n">${fmt(low)}</div><div class="l">หลักฐานต่ำ</div></div>
<div class="kpi blue"><div class="n">${fmt(rows.length)}</div><div class="l">รายการตาม filter</div></div>
</div>
${table(rows.slice(0,1800), [
{label:'ระดับ', html:r => `<span class="pill ${r.LEVEL === 'สูง' ? 'red' : r.LEVEL === 'กลาง' ? 'yellow' : 'blue'}">${esc(r.LEVEL)}</span>`},
{label:'คำแนะนำ', key:'RECOMMEND'}, {label:'Score', html:r => `<span class="mono">${esc(r.SCORE)}</span>`}, {label:'โรค', key:'GROUP'},
{label:'PID', html:r => `<span class="mono">${esc(r.PID)}</span>`}, {label:'CID', html:r => `<span class="mono">${esc(r.CID)}</span>`}, {label:'ชื่อ', key:'NAME'},
{label:'CHRONIC', key:'CHRONIC_SUGGEST'}, {label:'DATE_DIAG', html:r => `<span class="mono">${esc(r.DATE_DIAG)}</span>`},
{label:'ยา', html:r => `<strong>${fmt(r.DRUG_COUNT)}</strong> ครั้ง / ${fmt(r.DRUG_DATES)} วัน<br>${esc(r.DRUG_NAMES)}`},
{label:'LAB', html:r => `<strong>${fmt(r.LAB_COUNT)}</strong> ครั้ง / ${fmt(r.LAB_DATES)} วัน<br>${esc(r.LAB_TESTS)}`},
{label:'นัด', html:r => `<strong>${fmt(r.APPOINT_COUNT)}</strong> ครั้ง / ${fmt(r.APPOINT_DATES)} วัน`},
{label:'Diag', html:r => `<strong>${fmt(r.DIAG_COUNT)}</strong> ครั้ง / ${fmt(r.DIAG_DATES)} วัน`},
{label:'Evidence', key:'EVIDENCE'}
], 'ไม่พบ candidate ตาม filter นี้')}`;
}
function renderExport(){
const includeChronic = $('includeChronicAdds')?.checked;
const chronicAdds = hasData() && includeChronic ? buildRegisterCandidates().filter(x => x.LEVEL === 'สูง').length : 0;
$('exportInfo').innerHTML = hasData()
? `<div class="alert ${includeChronic ? 'warn' : ''}">พร้อม export: Missing FU ${fmt(state.audit.missing.length)}, SEQ problems ${fmt(state.audit.seqProblems.length)}, Incomplete FU ${fmt(state.audit.incomplete.length)}, Draft rows ${fmt(state.audit.drafts.length)}${includeChronic ? `, จะเพิ่ม CHRONIC หลักฐานสูง ${fmt(chronicAdds)} รายการ` : ', ไม่เพิ่ม CHRONIC ใน ZIP'}</div>`
: needUpload();
}
function exportWorkbook(){
if (!hasData()) { alert('กรุณาอัปโหลด ZIP ก่อน'); return; }
state.audit.drafts = buildDraftRows();
const wb = XLSX.utils.book_new();
const add = (name, rows) => XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(rows), name);
add('Missing_CHRONICFU', state.audit.missing);
add('CHRONICFU_SEQ_problem', state.audit.seqProblems);
add('CHRONICFU_incomplete', state.audit.incomplete);
add('Draft_CHRONICFU', state.audit.drafts);
state.audit.register = buildRegisterCandidates();
add('CHRONIC_register_review', state.audit.register);
add('HIS_fix_list', [
...state.audit.missing.map(x => ({ PRIORITY:x.level === 'high' ? 'High' : 'Medium', PID:x.pid, CID:x.cid, DATE_SERV:x.date, SEQ:x.seq, SERVICE_ROW:x.serviceRow, PROBLEM:x.reason, EVIDENCE:x.evidence, HIS_FIX:x.hisFix })),
...state.audit.seqProblems.map(x => ({ PRIORITY:'High', PID:x.pid, CID:x.cid, DATE_SERV:x.date, SEQ:x.seq, CHRONICFU_ROW:x.rowNum, PROBLEM:x.reason, EVIDENCE:'', HIS_FIX:x.hisFix })),
...state.audit.incomplete.map(x => ({ PRIORITY:x.level === 'high' ? 'High' : 'Medium', PID:x.pid, CID:x.cid, DATE_SERV:x.date, SEQ:x.seq, CHRONICFU_ROW:x.rowNum, PROBLEM:x.reason, MISSING_FIELDS:x.missingFields, EVIDENCE:'', HIS_FIX:x.hisFix }))
]);
XLSX.writeFile(wb, `CHRONICFU_SEQ_Audit_${state.hcode || 'HDC'}_${$('fy')?.value || currentThaiFiscalYear()}.xlsx`);
}
function cloneRows(rows){
return rows.map(row => row.slice());
}
function setRowValueByHeader(header, row, field, value){
const i = header.indexOf(field);
if (i >= 0) row[i] = value ?? '';
}
function objectToRowByHeader(header, obj){
return header.map(h => obj[h] ?? '');
}
function serializeTxtFile(fileObj, rows){
const header = fileObj.header;
const lines = [header.join('|'), ...rows.map(row => header.map((_,i) => row[i] ?? '').join('|'))];
return lines.join('\r\n') + '\r\n';
}
function chronicFuKeyFromObj(obj){
return `${obj.PID || ''}|${obj.SEQ || ''}|${obj.DATE_SERV || ''}`;
}
function applyChronicFuFixes(rows){
const file = state.files['chronicfu.txt'];
if (!file) return { added:0, updated:0, skipped:0 };
state.audit.drafts = buildDraftRows();
const header = file.header;
const keyToIndex = new Map();
rows.forEach((row,i) => {
const obj = {};
header.forEach((h,idx) => obj[h] = row[idx] ?? '');
keyToIndex.set(chronicFuKeyFromObj(obj), i);
});
let added = 0, updated = 0, skipped = 0;
state.audit.drafts.forEach(draft => {
if (!draft.PID || !draft.DATE_SERV || !draft.SEQ) { skipped++; return; }
const key = chronicFuKeyFromObj(draft);
const idx = keyToIndex.get(key);
if (idx >= 0) {
const row = rows[idx];
normalFields.forEach(field => {
if (header.includes(field) && !compact(row[header.indexOf(field)]) && compact(draft[field])) {
setRowValueByHeader(header, row, field, draft[field]);
}
});
['PROVIDER','CHRONICFUPLACE','CID','D_UPDATE'].forEach(field => {
if (header.includes(field) && !compact(row[header.indexOf(field)]) && compact(draft[field])) {
setRowValueByHeader(header, row, field, draft[field]);
}
});
updated++;
} else {
const out = {};
header.forEach(h => out[h] = draft[h] ?? '');
rows.push(objectToRowByHeader(header, out));
keyToIndex.set(key, rows.length - 1);
added++;
}
});
return { added, updated, skipped };
}
function buildChronicRowObject(candidate, header){
const obj = {};
header.forEach(h => obj[h] = '');
obj.HOSPCODE = state.hcode;
obj.PID = candidate.pid;
obj.CID = candidate.cid || '';
obj.DATE_DIAG = candidate.date || today8();
obj.CHRONIC = candidate.chronic;
obj.HOSP_DX = state.hcode;
obj.HOSP_RX = state.hcode;
obj.DATE_DISCH = '';
obj.TYPEDISCH = '03';
obj.D_UPDATE = today8();
return obj;
}
function applyChronicAdds(rows){
const file = state.files['chronic.txt'];
if (!file) return { added:0, skipped:0 };
const header = file.header;
const existing = new Set();
rows.forEach(row => {
const pid = row[header.indexOf('PID')] ?? '';
const code = row[header.indexOf('CHRONIC')] ?? '';
const group = chronicGroup(code);
if (pid && group) existing.add(`${pid}|${group}`);
});
let added = 0, skipped = 0;
buildRegisterCandidates().filter(x => x.LEVEL === 'สูง').forEach(c => {
const group = c.GROUP;
if (!c.PID || !c.CHRONIC_SUGGEST || existing.has(`${c.PID}|${group}`)) { skipped++; return; }
const obj = buildChronicRowObject({ pid:c.PID, cid:c.CID, group, chronic:c.CHRONIC_SUGGEST, date:c.DATE_DIAG }, header);
rows.push(objectToRowByHeader(header, obj));
existing.add(`${c.PID}|${group}`);
added++;
});
return { added, skipped };
}
async function exportFixed43Zip(){
if (!hasData()) { alert('กรุณาอัปโหลด ZIP ก่อน'); return; }
if (!state.files['chronicfu.txt']) { alert('ไม่พบ chronicfu.txt จึง export 43 ZIP แบบเติม CHRONICFU ไม่ได้'); return; }
if (!state.files['chronic.txt']) { alert('ไม่พบ chronic.txt จึงเพิ่มทะเบียน CHRONIC ไม่ได้'); return; }
state.audit.drafts = buildDraftRows();
const outRows = {};
Object.entries(state.files).forEach(([name, f]) => outRows[name] = cloneRows(f.rows));
const fuResult = applyChronicFuFixes(outRows['chronicfu.txt']);
const includeChronic = $('includeChronicAdds')?.checked;
const chronicResult = includeChronic ? applyChronicAdds(outRows['chronic.txt']) : { added:0, skipped:0 };
const zip = new JSZip();
Object.entries(state.files).forEach(([name, f]) => {
zip.file(name, serializeTxtFile(f, outRows[name]));
});
const blob = await zip.generateAsync({ type:'blob' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `F43_CHRONICFU_FIXED_${state.hcode || 'HDC'}_${today8()}.zip`;
a.click();
URL.revokeObjectURL(a.href);
alert(`Export 43 ZIP เสร็จแล้ว\nเพิ่ม CHRONICFU ${fuResult.added} แถว\nเติม CHRONICFU เดิม ${fuResult.updated} แถว\nข้ามเพราะไม่มี SEQ ${fuResult.skipped} แถว\nเพิ่ม CHRONIC ${chronicResult.added} แถว${includeChronic ? '' : ' (ปิดตัวเลือกเพิ่ม CHRONIC)'}`);
}
function copyBrief(){
if (!hasData()) { alert('กรุณาอัปโหลด ZIP ก่อน'); return; }
const text = [
`CHRONICFU/SEQ Audit HCODE ${state.hcode || '-'}`,
`CHRONIC active: ${state.audit.chronicPatients}`,
`SERVICE visits: ${state.audit.serviceVisits}`,
`Matched CHRONICFU: ${state.audit.matchedFu}`,
`SERVICE มีแต่ CHRONICFU ไม่ตรง/ไม่ออก: ${state.audit.missing.length}`,
`CHRONICFU SEQ ว่าง/ไม่ตรง SERVICE: ${state.audit.seqProblems.length}`,
`CHRONICFU vital/normal field ว่าง: ${state.audit.incomplete.length}`,
'',
'ข้อเสนอ: ตรวจหน้าจอบันทึก NCD/FU และ export mapping ให้ CHRONICFU generate ด้วย HOSPCODE+PID+SEQ+DATE_SERV ที่ตรง SERVICE'
].join('\n');
navigator.clipboard?.writeText(text).then(() => alert('Copy สรุปแล้ว')).catch(() => $('exportInfo').innerHTML = `<textarea style="width:100%;min-height:160px">${esc(text)}</textarea>`);
}
</script>
</body>
</html>