feat(message_bizppurio): 알림톡 실제 발송 내용을 발송 이력 화면에 별도 표시

코어 notification_logs.body는 알림톡의 경우 SMS 대체발송용 코어 템플릿
본문이라, 실제 카카오에 발송된 내용(승인 템플릿 전체)과 다르다. 코어
공용 파일은 다른 채널도 함께 쓰므로 직접 수정하지 않고, 결과 컬럼
오버레이 방식을 확장해 알림톡 채널일 때만 실제 발송 내용을 별도로
보여준다.
This commit is contained in:
chym1217
2026-07-28 12:40:16 +09:00
parent e41f9c14c3
commit efe1a81cb8
8 changed files with 167 additions and 4 deletions
@@ -102,6 +102,7 @@ HTTP/1.1 200
| is_low_balance | boolean | 잔액 부족(9070 문자 / 7436 알림톡) 여부 |
| fallback_status | string\|null | SMS 대체발송 결과 (webhook TELRES). 없으면 null |
| channel | string\|null | 발송 채널: `sms` / `lms` / `alimtalk` |
| content | string\|null | 실제 비즈뿌리오에 발송한 본문. 알림톡은 코어 `notification_logs.body`(대체발송용 코어 템플릿 값)와 달리 카카오 승인 템플릿의 실제 내용이므로, 화면은 이 값을 채널별로 구분해 별도 노출한다 |
**응답 예시**
@@ -95,6 +95,41 @@
"text": "$t:sirsoft-message_bizppurio.dispatch_result.fallback|status={{(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}"
}
]
},
{
"comment": "알림톡 실제 발송 내용 — 코어 '본문'(notification_logs.body)은 SMS 대체발송용 코어 템플릿 값이라 실제 카카오 발송 내용(승인 템플릿 전체)과 다르다. row.channel==='alimtalk'이고 실제 발송 내용이 있을 때만 별도 노출해 혼동을 방지한다.",
"type": "basic",
"name": "Div",
"if": "{{row.channel === 'alimtalk' && !!(dispatchResults?.data?.results ?? {})[row.id]?.content}}",
"props": {
"className": "pt-2 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-message_bizppurio.dispatch_result.sent_content_label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-200 whitespace-pre-line admin-card"
},
"text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.content}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-400 dark:text-gray-500 mt-1"
},
"text": "$t:sirsoft-message_bizppurio.dispatch_result.sent_content_hint"
}
]
}
]
}
@@ -36,6 +36,14 @@ function getResultCellChildren(): any[] {
return column.cellChildren;
}
/** overlay 에서 행 토글(expandChildren) 에 append 된 결과 블록을 추출한다. */
function getResultExpandChildren(): any[] {
const inj = (overlay as any).injections.find(
(i: any) => i.target_id === 'notification_log_datagrid' && i.position === 'inject_props',
);
return inj.props.expandChildren._append;
}
/** 결과 컬럼 cellChildren 을 iteration row 컨텍스트로 렌더하는 프로브 레이아웃. */
function buildProbe() {
return {
@@ -57,6 +65,27 @@ function buildProbe() {
};
}
/** 행 토글(expandChildren) 결과 블록을 iteration row 컨텍스트로 렌더하는 프로브 레이아웃. */
function buildExpandProbe() {
return {
version: '1.0.0',
layout_name: 'test/a2-dispatch-result-expand',
data_sources: [
{ id: 'notificationLogs', type: 'api', endpoint: '/api/test/logs', method: 'GET', auto_fetch: true },
{ id: 'dispatchResults', type: 'api', endpoint: '/api/test/results', method: 'POST', auto_fetch: true },
],
components: [
{
type: 'basic',
name: 'Div',
iteration: { source: '{{notificationLogs.data?.data ?? []}}', item_var: 'row' },
props: { 'data-testid': 'result-expand' },
children: getResultExpandChildren(),
},
],
};
}
let registry: MockComponentRegistry;
beforeEach(() => {
registry = createMockComponentRegistryWithBasics();
@@ -231,3 +260,80 @@ describe('A-2 결과 컬럼 주입 — 렌더', () => {
utils.cleanup();
});
});
describe('A-2 행 토글 — 알림톡 실제 발송 내용', () => {
it('알림톡 채널 + 실제 발송 내용이 있으면 코어 "본문"과 별도로 렌더한다', async () => {
const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([7], 'alimtalk') });
utils.mockApi('dispatchResults', {
response: {
data: {
results: {
7: {
status: 'success',
status_label: '성공',
result_label: '성공 (7000)',
is_low_balance: false,
fallback_status: null,
channel: 'alimtalk',
content: '[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.',
},
},
},
},
});
await utils.render();
expect(screen.getByText(/회원가입을 환영합니다/)).toBeInTheDocument();
utils.cleanup();
});
it('sms 채널은 실제 발송 내용 값이 있어도 렌더하지 않는다(코어 본문과 동일하므로 중복 표시 불필요)', async () => {
const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([8], 'sms') });
utils.mockApi('dispatchResults', {
response: {
data: {
results: {
8: {
status: 'success',
status_label: '성공',
result_label: '성공 (4100)',
is_low_balance: false,
fallback_status: null,
channel: 'sms',
content: '문자 본문',
},
},
},
},
});
await utils.render();
expect(screen.queryByText('문자 본문')).not.toBeInTheDocument();
utils.cleanup();
});
it('알림톡 채널이지만 실제 발송 내용이 없으면(과거 이력 등) 렌더하지 않는다', async () => {
const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([9], 'alimtalk') });
utils.mockApi('dispatchResults', {
response: {
data: {
results: {
9: {
status: 'success',
status_label: '성공',
result_label: '성공 (7000)',
is_low_balance: false,
fallback_status: null,
channel: 'alimtalk',
content: null,
},
},
},
},
});
await utils.render();
expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.sent_content_label')).not.toBeInTheDocument();
utils.cleanup();
});
});
@@ -113,7 +113,9 @@
"low_balance": "Low balance",
"fallback": "SMS fallback {status}",
"detail_title": "SMS/Alimtalk delivery result",
"channel_label": "Channel: {channel}"
"channel_label": "Channel: {channel}",
"sent_content_label": "Actual sent content",
"sent_content_hint": "Alimtalk messages are sent using the actual content of the approved Kakao template, which may differ from the \"Body\" above."
},
"editor": {
"data_source": {
@@ -113,7 +113,9 @@
"low_balance": "잔액 부족",
"fallback": "SMS 대체발송 {status}",
"detail_title": "문자·알림톡 발송 결과",
"channel_label": "발송 채널: {channel}"
"channel_label": "발송 채널: {channel}",
"sent_content_label": "실제 발송 내용",
"sent_content_hint": "알림톡은 카카오 승인 템플릿의 실제 내용으로 발송되며, 위 \"본문\"과 다를 수 있습니다."
},
"editor": {
"data_source": {
@@ -1467,7 +1467,7 @@
"comment": "조회 전용 — [내용] 버튼 하나. 상세 조회 후 내용 모달 열기(관리 액션 없음).",
"type": "basic",
"name": "Button",
"props": { "type": "button", "className": "btn btn-secondary btn-xs flex-center gap-1" },
"props": { "type": "button", "className": "btn btn-secondary btn-xs flex-center gap-1 whitespace-nowrap" },
"actions": [
{
"type": "click",
@@ -1482,7 +1482,6 @@
}
],
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "eye", "size": "sm" } },
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.actions.detail" }
]
}
@@ -94,6 +94,10 @@ class DispatchResultService
'channel' => $dispatch->channel?->value,
// 발송 시점 검수 모드 스냅샷. null=컬럼 신설 이전 이력(검수 여부 판단 불가 → 화면 미표시).
'is_test_mode' => $dispatch->is_test_mode,
// 실제 비즈뿌리오에 발송한 본문. 알림톡은 코어 notification_logs.body(대체발송용 코어
// 템플릿 본문)와 다른 값(카카오 승인 템플릿 실제 내용)이라, 코어 "본문" 표시만으로는
// 실제 발송 내용을 알 수 없다 — 화면이 채널별로 구분해 별도 노출할 수 있도록 포함.
'content' => $dispatch->content,
];
}
}
@@ -128,4 +128,18 @@ class DispatchResultServiceTest extends PluginTestCase
$this->assertSame('success', $results[60]['status']);
$this->assertTrue($results[61]['is_low_balance']);
}
/**
* 실제 발송 본문(dispatch.content)이 결과에 포함된다 — 알림톡은 코어
* notification_logs.body(대체발송용 코어 템플릿 값)와 실제 카카오 발송 내용이 달라,
* 화면이 채널별로 구분해 실제 발송 내용을 보여줄 수 있도록 결과에 담아야 한다.
*/
public function test_실제_발송_본문이_결과에_포함된다(): void
{
$this->linkedDispatch(80, ['content' => '[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.']);
$results = $this->service->resultsForLogIds([80]);
$this->assertSame('[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.', $results[80]['content']);
}
}