Template Service 구성 가이드
템플릿 작성 가이드
이 가이드는 Bahmni Template Service용 임상 문서 템플릿을 생성, 구성 및 사용자 지정하는 데 구현 담당자가 알아야 할 모든 내용을 다룹니다.
1. 개요
각 문서는 최대 4개 파일을 포함하는 템플릿 폴더입니다:
| File | Required | Purpose |
|---|---|---|
| template.html | Yes | Nunjucks HTML template — what gets rendered and printed |
| data-config.json | Recommended | Declares which OpenMRS APIs to fetch (sources only) |
| compute.js | Optional | Extracts and transforms data from resolved sources; fields returned here become {{ computed.* }} in the template |
| styles.css | Optional | Extra CSS that is inlined into the rendered HTML |
패턴:
- data-config.json은 가져올 항목(FHIR/REST 엔드포인트)을 선언합니다. compute.js는 필드 추출, 로직 적용, 입력 검증 등 가져온 데이터의 처리 방식을 결정합니다. template.html은 데이터 로직 없이 렌더링만 수행합니다.
서비스는 다음 순서로 템플릿을 렌더링합니다:
Request (templateId + context + data)
→ fetch data-config.json sources (FHIR / REST calls in parallel)
→ run compute.js (receives resolved sources + request data, returns fields)
→ render template.html (Nunjucks — uses {{ computed.* }}, {{ data.* }})
→ inline styles.css (if present)
→ return HTML템플릿 파일 변경 사항은 서비스를 다시 시작하지 않아도 다음 요청에 즉시 반영됩니다.
2. 폴더 구조
print-templates/
├── templates.json ← central registry of all templates
├── _i18n/
│ ├── en.json ← English translations
│ └── fr.json ← French translations (add more as needed)
├── _base/
│ ├── portrait.html ← base layout for portrait pages
│ └── landscape.html ← base layout for landscape pages
├── registration-card/
│ ├── template.html
│ ├── data-config.json
│ └── compute.js
└── prescriptions/
├── template.html
├── data-config.json
├── compute.js
└── styles.css3. templates.json — 템플릿 레지스트리
위치: print-templates/templates.json
{
"templates": [
{
"id": "REG_CARD_V1",
"name": "Registration Card",
"folder": "registration-card"
},
{
"id": "PRESCRIPTION_V1",
"name": "Prescription",
"folder": "prescriptions"
}
]
}| Field | Required | Description |
|---|---|---|
| id | Yes | Unique identifier — used in the render API call (templateId) |
| name | Yes | Human-readable name (returned by the list API) |
| folder | Yes | Subfolder name under print-templates/ |
4. data-config.json — 데이터 소스 선언
위치: print-templates/<folder>/data-config.json
data-config.json에는 sources 블록만 포함합니다. 호출할 OpenMRS 엔드포인트를 선언하며, 원시 응답은 resolved를 통해 compute.js로 전달됩니다.
{
"sources": {
"<sourceName>": {
"api": "fhir" | "rest",
"resource": "<path with {{contextVar}} placeholders>",
"params": { "<key>": "<value>" | ["<value1>", "<value2>"] }
}
}
}param 값은 일반 문자열(단일 값) 또는 문자열 배열(여러 값)일 수 있습니다. 배열은 같은 키를 여러 번 추가합니다. 이는 _include, _revinclude, _elements 및 유사한 반복 매개변수에 사용하는 표준 FHIR 다중 값 패턴입니다.
"_include": "MedicationRequest:encounter"
"_include": ["MedicationRequest:encounter", "MedicationRequest:medication"]{{contextVar}} 자리표시자는 렌더링 요청 context의 값(예: {{patientUuid}})으로 치환됩니다. 변수가 없으면 400 오류가 발생합니다.
URL 구성:
| api | How the URL is built |
|---|---|
| fhir | <OPENMRS_URL>/openmrs/ws/fhir2/R4/<resource>?<params> — resource is just the FHIR resource name, e.g. Patient |
| rest | <OPENMRS_URL><resource>?<params> — resource must be the full path from the domain root, e.g. /openmrs/ws/rest/v1/patientprofile/{{patientUuid}} |
예:
{
"sources": {
"patient": {
"api": "fhir",
"resource": "Patient",
"params": { "_id": "{{patientUuid}}" }
},
"relatives": {
"api": "fhir",
"resource": "RelatedPerson",
"params": { "patient": "{{patientUuid}}" }
},
"patientProfile": {
"api": "rest",
"resource": "/openmrs/ws/rest/v1/patientprofile/{{patientUuid}}",
"params": { "v": "full" }
},
"medicationRequests": {
"api": "fhir",
"resource": "MedicationRequest",
"params": {
"patient": "{{patientUuid}}",
"_include": ["MedicationRequest:encounter", "MedicationRequest:medication"],
"_sort": "-_lastUpdated",
"_count": "100"
}
}
}
}오류 동작:
| HTTP status | Behaviour |
|---|---|
| 200 | Returns response data |
| 400 | Returns empty Bundle { resourceType: 'Bundle', entry: [] } — does not throw |
| 401 | Throws — service returns a 401 session-expired response |
| 404 | Throws — service returns a 404 not-found response |
| Network timeout | Throws — service returns a 502 response |
5. compute.js — 데이터 추출 및 변환
위치: print-templates/<folder>/compute.js
compute.js는 resolved를 통해 원시 API 응답을 받고 data를 통해 호출자가 제공한 데이터를 받습니다. 템플릿에서 {{ computed.<key> }}로 사용할 수 있는 키를 가진 일반 객체를 반환합니다.
계약
module.exports = {
compute: async function ({ context, resolved, data, ValidationError, fhirPath, translate, locale }) {
// validate required context
if (!context?.patientUuid) throw new ValidationError('patientUuid is required');
const patient = resolved?.patient?.entry?.[0]?.resource;
// fhirPath — cleaner alternative to deep optional-chaining
const patientName = fhirPath(patient, 'Patient.name.first().text') ?? '';
const phone = fhirPath(patient, "Patient.telecom.where(system='phone').value") ?? '';
// use caller-supplied data if present
const printedBy = data?.printedBy ?? '';
// translate a key using the request locale
const genderLabel = translate(patient?.gender ?? '');
return { patientName, phone, printedBy, genderLabel };
},
};매개변수
| Parameter | Type | Description |
|---|---|---|
| context | object | UUIDs from the render request (patientUuid, visitUuid, etc.) |
| resolved | object | Raw API responses keyed by source name from data-config.json |
| data | object | Free-form JSON object passed in the render request data field |
| ValidationError | class | Throw this to return a 400 error to the caller |
| fhirPath | function(resource, expression) | FHIRPath evaluator — returns a single value, an array, or null |
| translate | function(key, overrideLocale?) | Returns the translated string for key using the request locale, falling back to English then the raw key |
| locale | string | The BCP 47 locale from the render request (e.g. "en", "fr") |
fhirPath는 \| fhirpathEvaluate Nunjucks 필터와 같은 평가기입니다. 수동 optional chaining 대신 전체 FHIRPath 표현식으로 FHIR 리소스를 조회하려면 compute.js에서 사용합니다.
translate는 | t Nunjucks 필터와 같은 기능입니다. 템플릿으로 표현할 수 없는 로직처럼 데이터에 따라 조회할 번역 키를 compute 스크립트에서 선택할 때 사용합니다.
// produce a translated label based on a FHIR code
const gender = resolved?.patient?.entry?.[0]?.resource?.gender; // 'male' | 'female'
return {
genderLabel: translate(gender), // → 'Homme' when locale is 'fr'
genderLabelEn: translate(gender, 'en'), // → 'Male' always
};- FHIR 소스는 Bundle을 반환하며 리소스는 entry[].resource 안에 있습니다. REST 소스는 응답 본문을 직접 반환합니다. data-config.json이 없으면 resolved가 undefined입니다. 요청에서 data를 제공하지 않으면 data가 undefined입니다. Bundle이 비어 있을 수 있으므로 항상 optional chaining(?.)을 사용합니다. 원시 HTTP 호출을 하거나 자격 증명을 하드코딩하지 마십시오. 모든 데이터 가져오기는 data-config.json에 정의합니다.
context — 사용 가능한 키
| Key | When present |
|---|---|
| patientUuid | Always |
| visitUuid | Print triggered from visit context |
| encounterUuid | Print triggered from encounter context |
반환값
일반 객체여야 합니다. null, undefined 또는 배열을 반환하면 결과를 오류 없이 건너뜁니다.
6. template.html — 렌더링
위치: print-templates/<folder>/template.html
템플릿은 Nunjucks — JavaScript's 에 해당하는 Jinja2.
템플릿 변수
| Variable | Description |
|---|---|
| {{ computed.<key> }} | Fields returned from compute.js |
| {{ data.<key> }} | Free-form data passed in the render request data field |
| {{ <sourceName> }} | Raw API response object from data-config.json sources — available directly, no compute.js needed |
| {{ locale }} | BCP 47 locale tag, e.g. "en", "fr" |
| {{ now }} | Date object at render time |
템플릿에서 소스 객체 직접 사용
data-config.json에 선언한 모든 소스는 해당 소스 이름으로 템플릿의 최상위 변수에서 사용할 수 있습니다. compute.js를 거치지 않고 원시 응답을 직접 탐색할 수 있습니다.
다음 data-config.json:
{
"sources": {
"patient": { "api": "fhir", "resource": "Patient", "params": { "_id": "{{patientUuid}}" } }
}
}The full FHIR Bundle 은 다음 변수로 사용할 수 있습니다: {{ patient }} in the template:
{# access nested fields directly #}
<p>{{ patient.entry[0].resource.name[0].text }}</p>
<p>{{ patient.entry[0].resource.birthDate }}</p>
<p>{{ patient.entry[0].resource.gender }}</p>
{# loop over bundle entries #}
{% for entry in patient.entry %}
<p>{{ entry.resource.name[0].text }}</p>
{% endfor %}
{# use fhirpathEvaluate for complex expressions #}
<p>{{ patient | fhirpathEvaluate("Bundle.entry.first().resource.name.first().text") }}</p>REST sources work the same way — the 응답 본문을 직접 사용할 수 있습니다:
"patientProfile": {
"api": "rest",
"resource": "/openmrs/ws/rest/v1/patientprofile/{{patientUuid}}",
"params": { "v": "full" }
}<p>{{ patientProfile.patient.auditInfo.dateCreated | dateFormat }}</p>직접 소스 접근과 compute.js의 사용 시점:
| Approach | Use when |
|---|---|
| Direct {{ sourceName.* }} | Simple field display, single template, no transformation needed |
| compute.js | Logic, grouping, conditionals, cross-source joins, or reuse across templates |
기본 레이아웃 확장
{% extends "_base/landscape.html" %}
{% block title %}{{ 'REGISTRATION_CARD' | t }}{% endblock %}
{% block styles %}
/* your custom CSS here */
{% endblock %}
{% block content %}
<!-- your content here -->
{% endblock %}일반적인 Nunjucks 패턴
대체값을 사용해 표시:
{{ computed.patientName | default('Unknown') }}조건문:
{% if computed.photoUrl %}
<img src="{{ computed.photoUrl }}" style="width: 20mm; height: 20mm;" />
{% endif %}
{% if data.showWatermark %}
<div class="watermark">DRAFT</div>
{% endif %}목록 반복:
{% for drug in computed.medications %}
<tr>
<td>{{ drug.drugName }}</td>
<td>{{ drug.dose | default('—') }}</td>
</tr>
{% else %}
<tr><td colspan="2">No medications.</td></tr>
{% endfor %}인덱스를 포함한 반복:
{% for item in computed.items %}
<p>{{ loop.index }}. {{ item.name }}</p>
{% endfor %}7. Nunjucks 필터 참조
서비스가 등록한 사용자 지정 필터이며 모든 템플릿에서 사용할 수 있습니다.
| t — 번역
에서 키를 조회합니다. _i18n/<locale>.json. 값이 없으면 영어, 그다음 원시 키를 사용합니다.
{{ 'PATIENT_NAME' | t }}
{{ 'WEIGHT' | t('fr') }} {# force French regardless of request locale #}
{{ 'NAME' | t }} / {{ 'NAME' | t('en') }} {# bilingual label #}| barcode(type, height) — 일반 바코드
base64 인코딩 PNG 바코드를 생성합니다. <img> tag. 출력은 HTML에서 안전합니다.
type에는 유효한 bwip-js bcid를 사용할 수 있습니다. 일반적인 유형은 다음과 같습니다.
| Type | Description |
|---|---|
| code128 | Most common — alphanumeric, compact |
| code39 | Older standard — uppercase letters and digits only |
| pdf417 | 2D, higher data density |
| datamatrix | 2D square matrix |
{{ computed.patientId | barcode('code128', 40) }}Falls back to <span class="barcode-fallback">value</span> 생성에 실패하면.
| qrcode(size) — QR 코드
QR 코드를 인라인 SVG로 생성합니다. 출력은 HTML에서 안전합니다. size는 픽셀 단위 너비와 높이입니다(기본값: 120).
{{ computed.patientId | qrcode(80) }}Falls back to <span class="qrcode-fallback">value</span> 생성에 실패하면.
Note: Put the QR 코드 in the same <td> as the patient ID text — not a separate extra column.
<td>
{{ computed.patientId | default('') }}<br>
{{ computed.patientId | qrcode(80) }}
</td>| dateFormat — 날짜 형식 지정
요청 로캘을 사용해 ISO 8601 날짜 문자열의 형식을 지정합니다.
{{ computed.registrationDate | dateFormat }} {# → "05 May 2026" #}| age — 생년월일로 나이 계산
사람이 읽을 수 있는 나이 문자열을 반환합니다. 가능하면 compute.js에서 나이를 계산해 직접 반환하고, 템플릿에 원시 생년월일이 있을 때만 이 필터를 사용하십시오.
{{ computed.birthDate | age }} {# → "33 years" / "4 months" / "14 days" #}| round(decimals) — 숫자 반올림
{{ computed.bmi | round(1) }} {# → "24.3" #}| fhirpathEvaluate(expression) — 인라인 FHIRPath
템플릿의 객체에 FHIRPath 표현식을 적용합니다. 원시 FHIR 리소스의 {% for %} 반복문 안에서 행별 값을 추출할 때 유용합니다.
{% for entry in patient.entry %}
<td>{{ entry.resource | fhirpathEvaluate("Patient.name.first().text") }}</td>
{% endfor %}Nunjucks 내장 필터
| Filter | Example | Output |
|---|---|---|
| \\| upper | {{ computed.gender \\| upper }} | MALE |
| \\| lower | {{ 'HELLO' \\| lower }} | hello |
| \\| capitalize | {{ computed.gender \\| capitalize }} | Male |
| \\| default(val) | {{ computed.phone \\| default('N/A') }} | N/A if empty |
| \\| first | {{ computed.items \\| first }} | First element |
| \\| last | {{ computed.items \\| last }} | Last element |
| \\| length | {{ computed.items \\| length }} | Count |
| \\| join(', ') | {{ computed.tags \\| join(', ') }} | "a, b, c" |
| \\| truncate(n) | {{ computed.notes \\| truncate(80) }} | First 80 chars + … |
전체 목록: Nunjucks 내장 필터
8. i18n — 번역
위치: print-templates/_i18n/<locale>.json
{
"PATIENT_NAME": "Patient Name",
"AGE": "Age",
"GENDER": "Gender",
"REGISTRATION": "Registration No.",
"DATED": "Date"
}_i18n/<locale-code>.json을 생성해 새 로캘을 추가합니다. | t 필터와 compute.js의 translate() 도우미는 요청 로캘 → 영어 → 원시 키 순으로 대체합니다.
번역 파일은 mtime 기준으로 캐시되며, 변경 사항은 재시작 없이 다음 요청에 반영됩니다.
9. styles.css — 사용자 지정 스타일
위치: print-templates/<folder>/styles.css
이 파일이 있으면 내용이 렌더링된 HTML에 인라인 <style> 블록으로 삽입됩니다(<head>가 있으면 그 안에, 없으면 앞에 추가). _base/*.html을 수정하지 않고 특정 템플릿의 기본 레이아웃 스타일을 재정의할 때 사용합니다.
10. 작업 예시 — 등록 카드
data-config.json
API 호출 세 개를 선언합니다. 여기서는 변환하지 않습니다.
{
"sources": {
"patient": {
"api": "fhir",
"resource": "Patient",
"params": { "_id": "{{patientUuid}}" }
},
"relatives": {
"api": "fhir",
"resource": "RelatedPerson",
"params": { "patient": "{{patientUuid}}" }
},
"patientProfile": {
"api": "rest",
"resource": "/openmrs/ws/rest/v1/patientprofile/{{patientUuid}}",
"params": { "v": "full" }
}
}
}compute.js
확인된 소스에서 모든 필드를 추출합니다.
module.exports = {
compute: async function ({ context, data, resolved, ValidationError }) {
const patient = resolved?.patient?.entry?.[0]?.resource;
const relative = resolved?.relatives?.entry?.[0]?.resource;
const profile = resolved?.patientProfile;
const officialId = patient?.identifier?.find((id) => id.use === 'official');
return {
patientId: officialId?.value ?? '',
patientName: patient?.name?.[0]?.text ?? '',
birthDate: patient?.birthDate ?? '',
age: computeAge(patient?.birthDate),
gender: patient?.gender ?? '',
phone: patient?.telecom?.find((t) => t.system === 'phone')?.value ?? '',
address: patient?.address?.[0]?.text ?? '',
village: patient?.address?.[0]?.city ?? '',
tehsil: patient?.address?.[0]?.district ?? '',
registrationDate: profile?.patient?.auditInfo?.dateCreated ?? '',
nextOfKinName: relative?.name?.[0]?.text ?? '',
nextOfKinRelationship: relative?.relationship?.[0]?.text ?? '',
photoUrl: `/openmrs/ws/rest/v1/patientImage?patientUuid=${context.patientUuid}`,
};
},
};
function computeAge(birthDate) {
if (!birthDate) return '';
const birth = new Date(birthDate);
const now = new Date();
const days = Math.floor((now - birth) / (1000 * 60 * 60 * 24));
if (days < 30) return `${days} days`;
const months = Math.floor(days / 30.44);
if (months < 12) return `${months} months`;
return `${Math.floor(months / 12)} years`;
}template.html
모든 값은 다음에서 가져옵니다: {{ computed.* }}.
{% extends "_base/landscape.html" %}
{% block content %}
<table class="registrationCard-details">
<tr>
<td>{{ 'DATED' | t }} :</td>
<td>{{ computed.registrationDate | dateFormat | default('') }}</td>
</tr>
<tr>
<td>{{ 'REGISTRATION' | t }} :</td>
<td>{{ computed.patientId | default('') }}</td>
</tr>
<tr>
<td>{{ 'NAME' | t }} :</td>
<td>{{ computed.patientName | default('') }}</td>
</tr>
<tr>
<td>{{ 'AGE' | t }} :</td>
<td>{{ computed.age | default('') }}</td>
</tr>
<tr>
<td>{{ 'GENDER' | t }} :</td>
<td>{{ computed.gender | upper | first | default('') }}</td>
</tr>
<tr>
<td>{{ 'ADDRESS' | t }} :</td>
<td>{{ computed.address | default('') }}</td>
</tr>
<tr>
<td>{{ 'VILLAGE' | t }} :</td>
<td>{{ computed.village | default('') }}</td>
</tr>
<tr>
<td>{{ 'TEHSIL' | t }} :</td>
<td>{{ computed.tehsil | default('') }}</td>
</tr>
</table>
{% if computed.photoUrl %}
<img src="{{ computed.photoUrl }}" style="width:20mm;height:20mm;"
onerror="this.style.display='none'" />
{% endif %}
{% endblock %}11. 작업 예시 — 처방전
data-config.json
{
"sources": {
"patient": {
"api": "fhir",
"resource": "Patient",
"params": { "_id": "{{patientUuid}}" }
},
"medicationRequests": {
"api": "fhir",
"resource": "MedicationRequest",
"params": {
"patient": "{{patientUuid}}",
"_include": ["MedicationRequest:encounter", "MedicationRequest:medication"],
"_sort": "-_lastUpdated",
"_count": "100"
}
}
}
}compute.js
입력을 검증하고 환자 필드를 추출하며 encounter별로 약물을 그룹화합니다.
module.exports = {
compute: async function ({ context, resolved, data, ValidationError }) {
if (!context?.patientUuid) throw new ValidationError('patientUuid is required');
if (!context?.visitUuid) throw new ValidationError('visitUuid is required');
const patient = resolved?.patient?.entry?.[0]?.resource;
const officialId = patient?.identifier?.find((id) => id.use === 'official');
const entries = resolved?.medicationRequests?.entry ?? [];
const byType = (type) => entries.filter(e => e.resource?.resourceType === type).map(e => e.resource);
const encounterResources = byType('Encounter');
const visitRef = `Encounter/${context.visitUuid}`;
const visitEncounterIds = new Set(
encounterResources
.filter(enc => enc.partOf?.reference === visitRef)
.map(enc => enc.id),
);
const encounterMap = new Map(encounterResources.map(e => [e.id, e]));
const byEncounter = new Map();
for (const mr of byType('MedicationRequest')) {
const encId = mr.encounter?.reference?.split('/')?.[1] ?? '';
if (!visitEncounterIds.has(encId)) continue;
if (!byEncounter.has(encId)) byEncounter.set(encId, []);
byEncounter.get(encId).push(mr);
}
const firstStart = [...byEncounter.keys()]
.map(id => encounterMap.get(id)?.period?.start)
.find(Boolean);
const encounters = [...byEncounter.entries()].map(([encId, meds]) => ({
doctorName: encounterMap.get(encId)?.participant?.[0]?.individual?.display ?? '',
drugOrders: meds.map(mr => {
const stopped = ['stopped', 'cancelled'].includes(mr.status);
return {
drugName: mr.medicationCodeableConcept?.text ?? '',
dosageInstructions: buildDosageInstructions(mr.dosageInstruction),
startDate: formatDate(mr.authoredOn ?? ''),
stopped,
stoppedDate: stopped ? formatDate(mr.dispenseRequest?.validityPeriod?.end ?? '') : '',
treatmentNotes: mr.note?.[0]?.text ?? '',
};
}),
}));
return {
patientName: patient?.name?.[0]?.text ?? '',
patientId: officialId?.value ?? '',
age: computeAge(patient?.birthDate),
gender: patient?.gender ?? '',
village: patient?.address?.[0]?.city ?? '',
district: patient?.address?.[0]?.district ?? '',
visitDate: firstStart ? formatDate(firstStart) : '',
encounters,
};
},
};
function computeAge(birthDate) {
if (!birthDate) return '';
const days = Math.floor((new Date() - new Date(birthDate)) / (1000 * 60 * 60 * 24));
if (days < 30) return `${days} days`;
const months = Math.floor(days / 30.44);
if (months < 12) return `${months} months`;
return `${Math.floor(months / 12)} years`;
}
function buildDosageInstructions(dosageInstruction) {
const d = dosageInstruction?.[0];
if (!d) return '';
const parts = [];
const doseQty = d.doseAndRate?.[0]?.doseQuantity;
if (doseQty?.value != null) parts.push(`${doseQty.value} ${doseQty.unit ?? ''}`.trim());
const frequency = d.timing?.code?.text;
if (frequency) parts.push(frequency);
if (d.asNeededBoolean) parts.push('SOS');
const route = d.route?.text;
if (route) parts.push(route);
const repeat = d.timing?.repeat;
if (repeat?.duration != null) return `${parts.join(', ')} - ${repeat.duration} ${durationLabel(repeat.durationUnit)}`;
return parts.join(', ');
}
function durationLabel(code) {
const map = { s: 'Second(s)', min: 'Minute(s)', h: 'Hour(s)', d: 'Day(s)', wk: 'Week(s)', mo: 'Month(s)', a: 'Year(s)' };
return map[code] ?? code ?? '';
}
function formatDate(value) {
if (!value) return '';
try {
const date = new Date(value);
if (isNaN(date.getTime())) return '';
return `${String(date.getDate()).padStart(2, '0')} ${date.toLocaleDateString('en', { month: 'long' })} ${date.getFullYear()}`;
} catch { return ''; }
}template.html
htmlwide760
Name: {{ computed.patientName | default('') }}
Age: {{ computed.age | default('') }} ({{ computed.gender | capitalize | default('') }})
Registration No: {{ computed.patientId | default('') }}
Village: {{ computed.village | default('') }}
District: {{ computed.district | default('') }}
Visit Date: {{ computed.visitDate | default('') }}
{% for encounter in computed.encounters %}
Consultation with {{ encounter.doctorName | default('') }}
S. No.Drug NameDosage InstructionsStart Date
{% for drug in encounter.drugOrders %}
{{ loop.index }}.
{{ drug.drugName }}
{{ drug.dosageInstructions | default('—') }}
{% if drug.stopped and drug.stoppedDate %}
stopped {{ drug.stoppedDate }}
{% endif %}
{{ drug.startDate | default('') }}
{% if drug.treatmentNotes %}
Treatment Notes: {{ drug.treatmentNotes }}
{% endif %}
{% endfor %}
{% else %}
No medications prescribed for this visit.
{% endfor %}
{% endblock %}]]>
{% extends "_base/portrait.html" %}
{% block content %}
<div class="patient-info">
<table>
<tr>
<td>Name: {{ computed.patientName | default('') }}</td>
<td>Age: {{ computed.age | default('') }} ({{ computed.gender | capitalize | default('') }})</td>
<td>Registration No: {{ computed.patientId | default('') }}</td>
</tr>
<tr>
<td>Village: {{ computed.village | default('') }}</td>
<td>District: {{ computed.district | default('') }}</td>
<td>Visit Date: {{ computed.visitDate | default('') }}</td>
</tr>
</table>
</div>
{% for encounter in computed.encounters %}
<h3>Consultation with {{ encounter.doctorName | default('') }}</h3>
<table class="prescription-table">
<tr>
<th>S. No.</th><th>Drug Name</th><th>Dosage Instructions</th><th>Start Date</th>
</tr>
{% for drug in encounter.drugOrders %}
<tbody>
<tr>
<td>{{ loop.index }}.</td>
<td class="{{ 'strike-text' if drug.stopped }}">{{ drug.drugName }}</td>
<td>
<span class="{{ 'strike-text' if drug.stopped }}">
{{ drug.dosageInstructions | default('—') }}
</span>
{% if drug.stopped and drug.stoppedDate %}
<span>stopped {{ drug.stoppedDate }}</span>
{% endif %}
</td>
<td>{{ drug.startDate | default('') }}</td>
</tr>
{% if drug.treatmentNotes %}
<tr>
<td></td><td></td>
<td colspan="2"><strong>Treatment Notes:</strong> {{ drug.treatmentNotes }}</td>
</tr>
{% endif %}
</tbody>
{% endfor %}
</table>
{% else %}
<p>No medications prescribed for this visit.</p>
{% endfor %}
{% endblock %}12. 호출자 데이터를 템플릿에 전달
렌더링 요청의 data 필드를 사용하면 OpenMRS API를 호출하지 않고 호출자가 임의의 값을 템플릿과 compute.js에 직접 주입할 수 있습니다.
요청:
{
"templateId": "PRESCRIPTION_V1",
"locale": "en",
"context": { "patientUuid": "...", "visitUuid": "..." },
"data": {
"printedBy": "Dr. Smith",
"showWatermark": true
}
}compute.js에서:
compute: async function ({ context, resolved, data, ValidationError }) {
return {
printedBy: data?.printedBy ?? '',
// ...
};
}template.html에서:
{% if data.showWatermark %}<div class="watermark">DRAFT</div>{% endif %}
<p>Printed by: {{ data.printedBy | default('') }}</p>13. 템플릿 테스트
템플릿을 생성하거나 편집한 뒤 실제 JSESSIONID를 사용해 curl로 테스트합니다(DevTools → Network → 임의 요청 → Request Headers → Cookie).
curl -s -X POST http://localhost:8080/template-service/api/render \
-H "Content-Type: application/json" \
-H "Cookie: JSESSIONID=<your-session-id>" \
-d '{
"templateId": "REG_CARD_V1",
"format": "html",
"locale": "en",
"context": { "patientUuid": "<real-patient-uuid>" }
}' > /tmp/rendered.html && open /tmp/rendered.htmlVerify barcodes and QR 코드s are present:
grep -c 'data:image/png;base64' /tmp/rendered.html # barcode count
grep -c '<svg' /tmp/rendered.html # qrcode count서비스 로그에서 오류 확인:
docker logs bahmni-standard-template-service-1 --tail 30등록된 모든 템플릿 나열:
curl -s http://localhost:8080/template-service/api/templates | jq .14. 흔한 실수
data-config.json에 computed 블록 배치
data-config.json에는 소스만 정의합니다. 모든 필드 추출은 compute.js에서 수행합니다.
// Wrong
{ "sources": { ... }, "computed": { ... } }
// Correct
{ "sources": { ... } }짧은 경로로 rest 사용
rest 소스에는 항상 도메인 루트부터의 전체 경로를 지정합니다.
// Wrong
{ "api": "rest", "resource": "patientprofile/{{patientUuid}}" }
// Correct
{ "api": "rest", "resource": "/openmrs/ws/rest/v1/patientprofile/{{patientUuid}}" }빈 Bundle을 처리하지 않음
// Unsafe
const patient = resolved.patient.entry[0].resource;
// Safe
const patient = resolved?.patient?.entry?.[0]?.resource;compute.js에서 객체 대신 배열 반환
// Wrong
return ['a', 'b'];
// Correct
return { items: ['a', 'b'] };QR 코드 in a separate table column
<!-- Wrong: extra column gets pushed off the visible card area -->
<td>{{ computed.patientId }}</td>
<td>{{ computed.patientId | qrcode(80) }}</td>
<!-- Correct: inline in the same cell -->
<td>
{{ computed.patientId | default('') }}<br>
{{ computed.patientId | qrcode(80) }}
</td>| qrcode 대신 | barcode('qrcode', ...) 사용
<!-- Wrong -->
{{ computed.patientId | barcode('qrcode', 80) }}
<!-- Correct -->
{{ computed.patientId | qrcode(80) }} {# QR code (SVG) #}
{{ computed.patientId | barcode('code128', 40) }} {# traditional barcode (PNG) #}입력 검증에서 일반 Error 발생
// Wrong — causes a 500 instead of 400
throw new Error('visitUuid is required');
// Correct
throw new ValidationError('visitUuid is required');15. API 참조
POST /template-service/api/render
요청 본문:
{
"templateId": "PRESCRIPTION_V1",
"format": "html",
"locale": "en",
"context": {
"patientUuid": "ae94ad73-ac02-42ab-aa04-fe658fb7c091",
"visitUuid": "14ee47a3-b093-4e0e-b718-d68fcf2666ab"
},
"data": {
"printedBy": "Dr. Smith"
}
}| Field | Required | Default | Description |
|---|---|---|---|
| templateId | Yes | — | ID from templates.json |
| format | No | "html" | Only "html" is supported |
| locale | No | "en" | BCP 47 locale tag for translations and date formatting |
| context | No | {} | UUIDs passed to data-config.json placeholders and compute.js |
| data | No | {} | Free-form object available as {{ data.* }} in templates and data in compute.js |
Response: { "html": "<rendered HTML string>" }
GET /template-service/api/templates
등록된 모든 템플릿을 반환합니다.
Response: { "templates": [{ "id": "...", "name": "..." }] }
GET /template-service/health
응답: { "status": "ok", "timestamp": "..." }
인증
다음 방법 중 하나로 사용자의 OpenMRS 세션을 전달합니다:
| Header | Value |
|---|---|
| Cookie | JSESSIONID=<session-id> |
| X-OpenMRS-Session-Id | <session-id> |
| X-OpenMRS-Authorization | Basic <base64> |
16. 환경 변수
| Variable | Default | Description |
|---|---|---|
| OPENMRS_URL | <http://openmrs:8080> | OpenMRS instance base URL |
| OPENMRS_TIMEOUT_MS | 10000 | API request timeout in milliseconds |
| TEMPLATES_DIR | /etc/bahmni_config/print-templates | Template directory mount path |
| PORT | 8080 | Service HTTP port |
| LOG_LEVEL | info | Log level (trace, debug, info, warn, error, fatal) |
Bahmni Wiki · CC BY-SA 4.0