BBahmni 한국어 매뉴얼검색
한국어 번역 완료
한국어English

Template Service 구성 가이드

템플릿 작성 가이드

이 가이드는 Bahmni Template Service용 임상 문서 템플릿을 생성, 구성 및 사용자 지정하는 데 구현 담당자가 알아야 할 모든 내용을 다룹니다.

1. 개요

각 문서는 최대 4개 파일을 포함하는 템플릿 폴더입니다:

FileRequiredPurpose
template.htmlYesNunjucks HTML template — what gets rendered and printed
data-config.jsonRecommendedDeclares which OpenMRS APIs to fetch (sources only)
compute.jsOptionalExtracts and transforms data from resolved sources; fields returned here become {{ computed.* }} in the template
styles.cssOptionalExtra 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.css

3. templates.json — 템플릿 레지스트리

위치: print-templates/templates.json

{
  "templates": [
    {
      "id": "REG_CARD_V1",
      "name": "Registration Card",
      "folder": "registration-card"
    },
    {
      "id": "PRESCRIPTION_V1",
      "name": "Prescription",
      "folder": "prescriptions"
    }
  ]
}
FieldRequiredDescription
idYesUnique identifier — used in the render API call (templateId)
nameYesHuman-readable name (returned by the list API)
folderYesSubfolder 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 구성:

apiHow the URL is built
fhir<OPENMRS_URL>/openmrs/ws/fhir2/R4/<resource>?<params> &mdash; resource is just the FHIR resource name, e.g. Patient
rest<OPENMRS_URL><resource>?<params> &mdash; 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 statusBehaviour
200Returns response data
400Returns empty Bundle { resourceType: 'Bundle', entry: [] } &mdash; does not throw
401Throws &mdash; service returns a 401 session-expired response
404Throws &mdash; service returns a 404 not-found response
Network timeoutThrows &mdash; service returns a 502 response

5. compute.js &mdash; 데이터 추출 및 변환

위치: 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 };
  },
};

매개변수

ParameterTypeDescription
contextobjectUUIDs from the render request (patientUuid, visitUuid, etc.)
resolvedobjectRaw API responses keyed by source name from data-config.json
dataobjectFree-form JSON object passed in the render request data field
ValidationErrorclassThrow this to return a 400 error to the caller
fhirPathfunction(resource, expression)FHIRPath evaluator &mdash; returns a single value, an array, or null
translatefunction(key, overrideLocale?)Returns the translated string for key using the request locale, falling back to English then the raw key
localestringThe 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 &mdash; 사용 가능한 키

KeyWhen present
patientUuidAlways
visitUuidPrint triggered from visit context
encounterUuidPrint triggered from encounter context

반환값

일반 객체여야 합니다. null, undefined 또는 배열을 반환하면 결과를 오류 없이 건너뜁니다.

6. template.html &mdash; 렌더링

위치: print-templates/<folder>/template.html

템플릿은 Nunjucks &mdash; JavaScript's 에 해당하는 Jinja2.

템플릿 변수

VariableDescription
{{ 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 &mdash; 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 &mdash; the 응답 본문을 직접 사용할 수 있습니다:

"patientProfile": {
  "api": "rest",
  "resource": "/openmrs/ws/rest/v1/patientprofile/{{patientUuid}}",
  "params": { "v": "full" }
}
<p>{{ patientProfile.patient.auditInfo.dateCreated | dateFormat }}</p>

직접 소스 접근과 compute.js의 사용 시점:

ApproachUse when
Direct {{ sourceName.* }}Simple field display, single template, no transformation needed
compute.jsLogic, 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 &mdash; 번역

에서 키를 조회합니다. _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) &mdash; 일반 바코드

base64 인코딩 PNG 바코드를 생성합니다. <img> tag. 출력은 HTML에서 안전합니다.

type에는 유효한 bwip-js bcid를 사용할 수 있습니다. 일반적인 유형은 다음과 같습니다.

TypeDescription
code128Most common &mdash; alphanumeric, compact
code39Older standard &mdash; uppercase letters and digits only
pdf4172D, higher data density
datamatrix2D square matrix
{{ computed.patientId | barcode('code128', 40) }}

Falls back to <span class="barcode-fallback">value</span> 생성에 실패하면.

| qrcode(size) &mdash; 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 &mdash; not a separate extra column.

<td>
  {{ computed.patientId | default('') }}<br>
  {{ computed.patientId | qrcode(80) }}
</td>

| dateFormat &mdash; 날짜 형식 지정

요청 로캘을 사용해 ISO 8601 날짜 문자열의 형식을 지정합니다.

{{ computed.registrationDate | dateFormat }}    {# → "05 May 2026" #}

| age &mdash; 생년월일로 나이 계산

사람이 읽을 수 있는 나이 문자열을 반환합니다. 가능하면 compute.js에서 나이를 계산해 직접 반환하고, 템플릿에 원시 생년월일이 있을 때만 이 필터를 사용하십시오.

{{ computed.birthDate | age }}    {# → "33 years" / "4 months" / "14 days" #}

| round(decimals) &mdash; 숫자 반올림

{{ computed.bmi | round(1) }}    {# → "24.3" #}

| fhirpathEvaluate(expression) &mdash; 인라인 FHIRPath

템플릿의 객체에 FHIRPath 표현식을 적용합니다. 원시 FHIR 리소스의 {% for %} 반복문 안에서 행별 값을 추출할 때 유용합니다.

{% for entry in patient.entry %}
  <td>{{ entry.resource | fhirpathEvaluate("Patient.name.first().text") }}</td>
{% endfor %}

Nunjucks 내장 필터

FilterExampleOutput
\\| 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 + &hellip;

전체 목록: Nunjucks 내장 필터관련 문서: Nunjucks built-in filters

8. i18n &mdash; 번역

위치: 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 &mdash; 사용자 지정 스타일

위치: print-templates/<folder>/styles.css

이 파일이 있으면 내용이 렌더링된 HTML에 인라인 <style> 블록으로 삽입됩니다(<head>가 있으면 그 안에, 없으면 앞에 추가). _base/*.html을 수정하지 않고 특정 템플릿의 기본 레이아웃 스타일을 재정의할 때 사용합니다.

10. 작업 예시 &mdash; 등록 카드

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. 작업 예시 &mdash; 처방전

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.html

Verify 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"
  }
}
FieldRequiredDefaultDescription
templateIdYes&mdash;ID from templates.json
formatNo"html"Only "html" is supported
localeNo"en"BCP 47 locale tag for translations and date formatting
contextNo{}UUIDs passed to data-config.json placeholders and compute.js
dataNo{}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 세션을 전달합니다:

HeaderValue
CookieJSESSIONID=<session-id>
X-OpenMRS-Session-Id<session-id>
X-OpenMRS-AuthorizationBasic <base64>

16. 환경 변수

VariableDefaultDescription
OPENMRS_URL<http://openmrs:8080>OpenMRS instance base URL
OPENMRS_TIMEOUT_MS10000API request timeout in milliseconds
TEMPLATES_DIR/etc/bahmni_config/print-templatesTemplate directory mount path
PORT8080Service HTTP port
LOG_LEVELinfoLog level (trace, debug, info, warn, error, fatal)
원문 정보

Bahmni Wiki · CC BY-SA 4.0

원문 보기 ↗