giftcon_dev/app/Services/Admin/Qna/AdminQnaService.php
2026-02-12 15:13:55 +09:00

821 lines
31 KiB
PHP

<?php
namespace App\Services\Admin\Qna;
use App\Repositories\Admin\Cs\AdminQnaRepository;
use App\Services\SmsService;
use App\Support\LegacyCrypto\CiSeedCrypto;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use App\Jobs\SendUserQnaAnsweredMailJob;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use App\Services\Admin\AdminAuditService;
final class AdminQnaService
{
public function __construct(
private readonly AdminQnaRepository $repo,
private readonly AdminAuditService $audit,
) {}
public function stateLabels(): array
{
return [
'a' => ['접수', 'pill--muted'], // 회색(대기/접수)
'b' => ['분배완료', 'pill--info'], // 파랑(분배/할당 완료)
'c' => ['처리중', 'pill--warn'], // 주황(진행중)
'd' => ['보류', 'pill--bad'], // 빨강(중단/보류)
'e' => ['완료', 'pill--ok'], // 초록(완료)
];
}
public function paginate(array $filters, int $perPage, int $adminId): array
{
$year = (int)($filters['year'] ?? date('Y'));
if ($year <= 2000) $year = (int)date('Y');
$filters['admin_id'] = $adminId;
$rows = $this->repo->paginate($year, $filters, $perPage);
if (is_object($rows) && method_exists($rows, 'withQueryString')) {
$rows->withQueryString();
}
return [
'year' => $year,
'rows' => $this->repo->paginate($year, $filters, $perPage),
'enquiryCodes' => $this->repo->getEnquiryCodes(),
'stateLabels' => $this->stateLabels(),
'categories' => array_values($this->categoriesByNum()),
'categoriesMap' => $this->categoriesByNum(),
];
}
public function detail(int $seq, int $year, int $adminId): array
{
// 1) row
$row = $this->repo->findOrFail($year, $seq);
// 2) logs decode (state/memo)
$log = $this->decodeJson((string)($row->admin_change_memo ?? ''));
$stateLog = array_reverse($log['state_log'] ?? []);
$memoLog = array_reverse($log['memo'] ?? []);
// 3) adminIds build (stateLog + memoLog + answer_admin_num) : set(연관배열)로 중복 제거
$adminSet = [];
foreach ($stateLog as $x) {
$id = (int)($x['admin_num'] ?? 0);
if ($id > 0) $adminSet[$id] = true;
}
foreach ($memoLog as $x) {
$id = (int)($x['admin_num'] ?? 0);
if ($id > 0) $adminSet[$id] = true;
}
$ans = (int)($row->answer_admin_num ?? 0);
if ($ans > 0) $adminSet[$ans] = true;
$adminIds = array_keys($adminSet);
// 4) admins
$admins = $adminIds ? $this->repo->getAdminsByIds($adminIds) : collect();
// 5) member + decrypt phone
$member = $this->repo->getMemberInfo((int)($row->member_num ?? 0));
if ($member && !empty($member->cell_phone)) {
$seed = app(\App\Support\LegacyCrypto\CiSeedCrypto::class);
$member->cell_phone = (string) $seed->decrypt((string)$member->cell_phone);
}
// 6) 최근 거래 10건: 당분간 무조건 0건 (쿼리 절대 X)
$orders = [];
// 7) codes cache
$enquiryCodes = \Cache::remember('cs:enquiry_codes', 3600, fn () => $this->repo->getEnquiryCodes());
$deferCodes = \Cache::remember('cs:defer_codes', 3600, fn () => $this->repo->getDeferCodes());
// 8) actions + 내부 메모 권한(배정 후부터, 내 업무만)
$actions = $this->allowedActions($row, $adminId);
$state = (string)($row->state ?? '');
$assignedToMe = ((int)($row->answer_admin_num ?? 0) === $adminId);
$actions['canMemo'] = ($state !== 'a') && $assignedToMe; // a(접수) 금지, b/c/d/e OK
// 9) categories (config/cs_faq.php 기반 num 매핑)
$categoriesMap = $this->categoriesByNum();
return [
'row' => $row,
'member' => $member,
'orders' => $orders,
'stateLabels' => $this->stateLabels(),
'enquiryCodes' => $enquiryCodes,
'deferCodes' => $deferCodes,
'stateLog' => $stateLog,
'memoLog' => $memoLog,
'admins' => $admins,
'actions' => $actions,
'categories' => array_values($categoriesMap),
'categoriesMap' => $categoriesMap,
];
}
public function assignToMe(int $seq, int $year, int $adminId): void
{
DB::transaction(function () use ($seq, $year, $adminId) {
$row = $this->repo->lockForUpdate($year, $seq, [
'seq','state','answer_admin_num','admin_change_memo'
]);
if ($row->state !== 'a') {
throw ValidationException::withMessages([
'answer_content' => '현재 상태에서는 배정할 수 없습니다.',
]);
}
// ✅ 감사로그 before (변경 전)
$beforeAudit = [
'year' => $year,
'seq' => $seq,
'state' => (string)($row->state ?? ''),
'answer_admin_num' => (int)($row->answer_admin_num ?? 0),
];
$log = $this->decodeJson((string)($row->admin_change_memo ?? ''));
$log = $this->pushStateLog($log, $adminId, $row->state, 'b', '업무를 직접 배정');
$payload = [
'state' => 'b',
'answer_admin_num' => $adminId,
'receipt_date' => now()->format('Y-m-d H:i:s'),
'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE),
];
$this->repo->update($year, $seq, $payload);
// ✅ 감사로그 after (변경 후) — 감사로그 실패해도 본 로직 영향 X
try {
$req = request();
$afterAudit = $beforeAudit;
$afterAudit['state'] = 'b';
$afterAudit['answer_admin_num'] = $adminId;
$afterAudit['receipt_date'] = $payload['receipt_date'];
$this->audit->log(
actorAdminId: $adminId,
action: 'admin.qna.assign_to_me',
targetType: 'qna',
targetId: (int)$seq, // year가 분리키면 before/after에 이미 남김
before: $beforeAudit,
after: $afterAudit,
ip: (string)($req?->ip() ?? ''),
ua: (string)($req?->userAgent() ?? ''),
);
} catch (\Throwable $ignored) {}
});
}
public function startWork(int $seq, int $year, int $adminId): void
{
DB::transaction(function () use ($seq, $year, $adminId) {
$row = $this->repo->lockForUpdate($year, $seq, [
'seq','state','answer_admin_num','admin_change_memo'
]);
$assigned = (int)($row->answer_admin_num ?? 0);
if ($assigned !== $adminId) abort(403);
if (!in_array($row->state, ['b','d'], true)) {
throw ValidationException::withMessages([
'answer_content' => '현재 상태에서는 업무 시작이 불가합니다.',
]);
}
// ✅ 감사로그 before (변경 전)
$beforeAudit = [
'year' => $year,
'seq' => $seq,
'state' => (string)($row->state ?? ''),
'answer_admin_num' => (int)($row->answer_admin_num ?? 0),
];
$msg = ($row->state === 'd') ? '업무를 재개합니다!' : '업무를 시작했습니다.';
$log = $this->decodeJson((string)($row->admin_change_memo ?? ''));
$log = $this->pushStateLog($log, $adminId, $row->state, 'c', $msg);
$this->repo->update($year, $seq, [
'state' => 'c',
'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE),
]);
// ✅ 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X)
try {
$req = request();
$afterAudit = $beforeAudit;
$afterAudit['state'] = 'c';
$this->audit->log(
actorAdminId: $adminId,
action: 'admin.qna.start_work',
targetType: 'qna',
targetId: (int)$seq,
before: $beforeAudit,
after: $afterAudit,
ip: (string)($req?->ip() ?? ''),
ua: (string)($req?->userAgent() ?? ''),
);
} catch (\Throwable $ignored) {}
});
}
public function returnWork(int $seq, int $year, int $adminId): void
{
DB::transaction(function () use ($seq, $year, $adminId) {
$row = $this->repo->lockForUpdate($year, $seq, [
'seq','state','answer_admin_num','admin_change_memo'
]);
$assigned = (int)($row->answer_admin_num ?? 0);
if ($assigned !== $adminId) abort(403);
if (!in_array($row->state, ['b','c','d'], true)) {
throw ValidationException::withMessages([
'answer_content' => '현재 상태에서는 반납할 수 없습니다.',
]);
}
// ✅ 감사로그 before (변경 전)
$beforeAudit = [
'year' => $year,
'seq' => $seq,
'state' => (string)($row->state ?? ''),
'answer_admin_num' => (int)($row->answer_admin_num ?? 0),
];
$log = $this->decodeJson((string)($row->admin_change_memo ?? ''));
$log = $this->pushStateLog($log, $adminId, $row->state, 'a', '업무를 종료/반납했습니다.');
$this->repo->update($year, $seq, [
'state' => 'a',
'answer_admin_num' => null,
'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE),
]);
// ✅ 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X)
try {
$req = request();
$afterAudit = $beforeAudit;
$afterAudit['state'] = 'a';
$afterAudit['answer_admin_num'] = null;
$this->audit->log(
actorAdminId: $adminId,
action: 'admin.qna.return_work',
targetType: 'qna',
targetId: (int)$seq,
before: $beforeAudit,
after: $afterAudit,
ip: (string)($req?->ip() ?? ''),
ua: (string)($req?->userAgent() ?? ''),
);
} catch (\Throwable $ignored) {}
});
}
public function postponeWork(int $seq, int $year, int $adminId, string $deferCode, string $comment): void
{
DB::transaction(function () use ($seq, $year, $adminId, $deferCode, $comment) {
$row = $this->repo->lockForUpdate($year, $seq, [
'seq','state','answer_admin_num','admin_change_memo'
]);
$assigned = (int)($row->answer_admin_num ?? 0);
if ($assigned !== $adminId) abort(403);
if (!in_array($row->state, ['b','c'], true)) {
throw ValidationException::withMessages([
'answer_content' => '현재 상태에서는 보류할 수 없습니다.',
]);
}
$comment = trim($comment);
if ($comment === '') {
throw ValidationException::withMessages([
'answer_content' => '보류 사유를 입력해 주세요.',
]);
}
// ✅ 감사로그 before (변경 전)
$beforeAudit = [
'year' => $year,
'seq' => $seq,
'state' => (string)($row->state ?? ''),
'answer_admin_num' => (int)($row->answer_admin_num ?? 0),
];
$log = $this->decodeJson((string)($row->admin_change_memo ?? ''));
$log = $this->pushStateLog($log, $adminId, $row->state, 'd', $comment);
$payload = [
'state' => 'd',
'defer_date' => now()->format('Y-m-d H:i:s'),
'defer_comment' => $comment,
'defer_admin_num' => $adminId,
'defer_code' => $deferCode ?: null,
'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE),
];
$this->repo->update($year, $seq, $payload);
// ✅ 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X)
try {
$req = request();
$afterAudit = $beforeAudit;
$afterAudit['state'] = 'd';
$afterAudit['defer_date'] = $payload['defer_date'];
$afterAudit['defer_admin_num'] = $adminId;
$afterAudit['defer_code'] = $payload['defer_code'];
// comment는 길거나 민감할 수 있으니 전체 대신 길이/해시만 남김(원문 필요하면 여기만 변경)
$afterAudit['defer_comment_len'] = mb_strlen($comment);
$afterAudit['defer_comment_sha256'] = hash('sha256', $comment);
$this->audit->log(
actorAdminId: $adminId,
action: 'admin.qna.postpone_work',
targetType: 'qna',
targetId: (int)$seq,
before: $beforeAudit,
after: $afterAudit,
ip: (string)($req?->ip() ?? ''),
ua: (string)($req?->userAgent() ?? ''),
);
} catch (\Throwable $ignored) {}
});
}
public function saveAnswer(int $seq, int $year, int $adminId, string $answerContent, string $answerSms): void
{
DB::transaction(function () use ($seq, $year, $adminId, $answerContent, $answerSms) {
$row = $this->repo->lockForUpdate($year, $seq, [
'seq','state','answer_admin_num','admin_change_memo'
]);
$this->assertCanMemo($row, $adminId);
$assigned = (int)($row->answer_admin_num ?? 0);
if ($assigned !== $adminId) abort(403);
if (!in_array($row->state, ['c'], true)) {
throw ValidationException::withMessages([
'answer_content' => '처리중 상태에서만 답변 저장이 가능합니다.',
]);
}
$answerContent = trim($answerContent);
$answerSms = trim($answerSms);
if ($answerContent === '') abort(422, '관리자 답변을 입력해 주세요.');
if ($answerSms === '') abort(422, 'SMS 답변을 입력해 주세요.');
// ✅ 감사로그 before (변경 전) — 원문은 저장하지 않고 길이/해시만
$beforeAudit = [
'year' => $year,
'seq' => $seq,
'state' => (string)($row->state ?? ''),
'answer_admin_num' => (int)($row->answer_admin_num ?? 0),
];
$log = $this->decodeJson((string)($row->admin_change_memo ?? ''));
$log = $this->pushMemoLog($log, $adminId, '관리자 답변 수정');
$this->repo->update($year, $seq, [
'answer_content' => $answerContent,
'answer_sms' => $answerSms,
'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE),
]);
// ✅ 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X)
try {
$req = request();
$afterAudit = $beforeAudit;
$afterAudit['answer_content_len'] = mb_strlen($answerContent);
$afterAudit['answer_content_sha256'] = hash('sha256', $answerContent);
$afterAudit['answer_sms_len'] = mb_strlen($answerSms);
$afterAudit['answer_sms_sha256'] = hash('sha256', $answerSms);
$this->audit->log(
actorAdminId: $adminId,
action: 'admin.qna.save_answer',
targetType: 'qna',
targetId: (int)$seq,
before: $beforeAudit,
after: $afterAudit,
ip: (string)($req?->ip() ?? ''),
ua: (string)($req?->userAgent() ?? ''),
);
} catch (\Throwable $ignored) {}
});
}
public function completeWork(int $seq, int $year, int $adminId): void
{
$notify = null;
DB::transaction(function () use ($seq, $year, $adminId, &$notify) {
$row = $this->repo->lockForUpdate($year, $seq, [
'seq','state','answer_admin_num','admin_change_memo',
'return_type','member_num','enquiry_title','enquiry_content',
'answer_content','answer_sms','regdate',
]);
$assigned = (int)($row->answer_admin_num ?? 0);
if ($assigned !== $adminId) abort(403);
if (!in_array($row->state, ['b','c'], true)) {
throw ValidationException::withMessages([
'answer_content' => '현재 상태에서는 완료 처리가 불가합니다.',
]);
}
// ✅ 감사로그 before (변경 전)
$beforeAudit = [
'year' => $year,
'seq' => $seq,
'state' => (string)($row->state ?? ''),
'answer_admin_num' => (int)($row->answer_admin_num ?? 0),
'return_type' => (string)($row->return_type ?? 'web'),
'member_num' => (int)($row->member_num ?? 0),
];
$answerContent = trim((string)($row->answer_content ?? ''));
$answerSms = trim((string)($row->answer_sms ?? ''));
if ($answerContent === '') {
throw ValidationException::withMessages([
'answer_content' => '답변 내용이 비어있습니다. 먼저 저장해 주세요.',
]);
}
$rt = (string)($row->return_type ?? 'web');
if (in_array($rt, ['sms','phone'], true) && $answerSms === '') {
abort(422, 'SMS 답변이 비어있습니다.');
}
$log = $this->decodeJson((string)($row->admin_change_memo ?? ''));
$log = $this->pushStateLog($log, $adminId, $row->state, 'e', '업무를 완료했습니다.');
$completionDate = now()->format('Y-m-d H:i:s');
$this->repo->update($year, $seq, [
'state' => 'e',
'completion_date' => $completionDate,
'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE),
]);
// ✅ 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X)
try {
$req = request();
$afterAudit = $beforeAudit;
$afterAudit['state'] = 'e';
$afterAudit['completion_date'] = $completionDate;
// 답변 원문은 감사로그에 저장하지 않고 길이/해시만
$afterAudit['answer_content_len'] = mb_strlen($answerContent);
$afterAudit['answer_content_sha256'] = hash('sha256', $answerContent);
$afterAudit['answer_sms_len'] = mb_strlen($answerSms);
$afterAudit['answer_sms_sha256'] = hash('sha256', $answerSms);
$this->audit->log(
actorAdminId: $adminId,
action: 'admin.qna.complete_work',
targetType: 'qna',
targetId: (int)$seq,
before: $beforeAudit,
after: $afterAudit,
ip: (string)($req?->ip() ?? ''),
ua: (string)($req?->userAgent() ?? ''),
);
} catch (\Throwable $ignored) {}
// 트랜잭션 밖에서 발송하도록 데이터만 준비
$notify = [
'return_type' => $rt,
'member_num' => (int)$row->member_num,
'enquiry_title' => (string)$row->enquiry_title,
'enquiry_content' => (string)$row->enquiry_content,
'answer_content' => $answerContent,
'answer_sms' => $answerSms,
'regdate' => (string)$row->regdate,
'year' => $year,
'seq' => (int)$row->seq,
];
});
if (is_array($notify)) {
DB::afterCommit(function () use ($notify) {
$this->sendResultToUser($notify);
});
}
}
public function addMemo(int $seq, int $year, int $adminId, string $memo): void
{
DB::transaction(function () use ($seq, $year, $adminId, $memo) {
$memo = trim($memo);
if ($memo === '') abort(422);
$row = $this->repo->lockForUpdate($year, $seq, [
'seq','answer_admin_num','admin_change_memo'
]);
// 메모는 배정자만 제한할지, 모든 관리자 가능할지 선택. 관리자 모두
// if ((int)($row->answer_admin_num ?? 0) !== $adminId) abort(403);
// ✅ 감사로그 before (변경 전) — 메모 원문은 저장하지 않음
$beforeAudit = [
'year' => $year,
'seq' => $seq,
'answer_admin_num' => (int)($row->answer_admin_num ?? 0),
];
$log = $this->decodeJson((string)($row->admin_change_memo ?? ''));
$log = $this->pushMemoLog($log, $adminId, $memo);
$this->repo->update($year, $seq, [
'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE),
]);
// ✅ 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X)
try {
$req = request();
$afterAudit = $beforeAudit;
$afterAudit['memo_len'] = mb_strlen($memo);
$afterAudit['memo_sha256'] = hash('sha256', $memo);
$this->audit->log(
actorAdminId: $adminId,
action: 'admin.qna.memo.add',
targetType: 'qna',
targetId: (int)$seq,
before: $beforeAudit,
after: $afterAudit,
ip: (string)($req?->ip() ?? ''),
ua: (string)($req?->userAgent() ?? ''),
);
} catch (\Throwable $ignored) {}
});
}
private function allowedActions(object $row, int $adminId): array
{
$state = (string)$row->state;
$assigned = (int)($row->answer_admin_num ?? 0);
return [
'can_assign' => ($state === 'a'),
'can_start' => ($assigned === $adminId && in_array($state, ['b','d'], true)),
'can_return' => ($assigned === $adminId && in_array($state, ['b','c','d'], true)),
'can_postpone' => ($assigned === $adminId && in_array($state, ['b','c'], true)),
'can_answer' => ($assigned === $adminId && $state === 'c'),
'can_complete' => ($assigned === $adminId && in_array($state, ['b','c'], true)),
];
}
private function decodeJson(string $json): array
{
$json = trim($json);
if ($json === '') return [];
$arr = json_decode($json, true);
return is_array($arr) ? $arr : [];
}
private function pushStateLog(array $log, int $adminId, string $before, string $after, string $why): array
{
$log['state_log'] ??= [];
$log['state_log'][] = [
'admin_num' => $adminId,
'state_before' => $before,
'state_after' => $after,
'Who' => 'admin',
'why' => $why,
'when' => now()->format('y-m-d H:i:s'),
];
return $log;
}
private function pushMemoLog(array $log, int $adminId, string $memo): array
{
$log['memo'] ??= [];
$log['memo'][] = [
'admin_num' => $adminId,
'memo' => $memo,
'when' => now()->format('y-m-d H:i:s'),
];
return $log;
}
private function maskEmail(string $email): string
{
$email = trim($email);
if ($email === '' || !str_contains($email, '@')) return '';
[$id, $dom] = explode('@', $email, 2);
$idLen = mb_strlen($id);
if ($idLen <= 2) return str_repeat('*', $idLen) . '@' . $dom;
return mb_substr($id, 0, 2) . str_repeat('*', max(1, $idLen - 2)) . '@' . $dom;
}
private function maskPhone(string $digits): string
{
$digits = preg_replace('/\D+/', '', $digits) ?? '';
$len = strlen($digits);
if ($len <= 4) return str_repeat('*', $len);
return substr($digits, 0, 3) . str_repeat('*', max(0, $len - 7)) . substr($digits, -4);
}
private function sendResultToUser(array $data): void
{
$memberNum = (int)($data['member_num'] ?? 0);
$rt = (string)($data['return_type'] ?? 'web');
$year = (int)($data['year'] ?? date('Y'));
$qnaId = (int)($data['seq'] ?? $data['qna_id'] ?? 0);
Log::info('[QNA][result] enter', [
'member_num' => $memberNum,
'qna_id' => $qnaId,
'year' => $year,
'return_type' => $rt,
'has_answer_content' => (trim((string)($data['answer_content'] ?? '')) !== ''),
'answer_sms_len' => mb_strlen((string)($data['answer_sms'] ?? '')),
]);
if ($memberNum <= 0 || $qnaId <= 0) {
Log::warning('[QNA][result] invalid keys', [
'member_num' => $memberNum,
'qna_id' => $qnaId,
]);
return;
}
$member = $this->repo->getMemberInfo($memberNum);
if (!$member) {
Log::warning('[QNA][result] member not found', [
'member_num' => $memberNum,
]);
return;
}
// EMAIL
if ($rt === 'email') {
$to = trim((string)($member->email ?? ''));
if ($to === '') {
Log::warning('[QNA][result][email] no email', [
'member_num' => $memberNum,
'qna_id' => $qnaId,
]);
return;
}
Log::info('[QNA][result][email] dispatching', [
'to' => $this->maskEmail($to),
'connection' => 'redis',
'queue' => 'mail',
'after_commit' => true,
]);
SendUserQnaAnsweredMailJob::dispatch(
$to,
$memberNum,
$qnaId,
[
'year' => $year,
'enquiry_code' => (string)($data['enquiry_code'] ?? ''),
'enquiry_title' => (string)($data['enquiry_title'] ?? ''),
'enquiry_content' => (string)($data['enquiry_content'] ?? ''),
'answer_content' => (string)($data['answer_content'] ?? ''),
'answer_sms' => (string)($data['answer_sms'] ?? ''),
'regdate' => (string)($data['regdate'] ?? ''),
'completed_at' => (string)($data['completed_at'] ?? now()->toDateTimeString()),
]
)
->onConnection('redis')
->onQueue('mail')
->afterCommit();
Log::info('[QNA][result][email] dispatched', [
'to' => $this->maskEmail($to),
'member_num' => $memberNum,
'qna_id' => $qnaId,
]);
return;
}
// SMS
if (in_array($rt, ['sms', 'phone'], true)) {
$msg = trim((string)($data['answer_sms'] ?? ''));
if ($msg === '') {
Log::warning('[QNA][result][sms] empty answer_sms', [
'member_num' => $memberNum,
'qna_id' => $qnaId,
]);
return;
}
$phoneEnc = (string)($member->cell_phone ?? '');
if ($phoneEnc === '') {
Log::warning('[QNA][result][sms] empty cell_phone', [
'member_num' => $memberNum,
'qna_id' => $qnaId,
]);
return;
}
try {
$seed = app(CiSeedCrypto::class);
$phonePlain = (string)$seed->decrypt($phoneEnc);
$digits = preg_replace('/\D+/', '', $phonePlain) ?? '';
if ($digits === '') {
Log::warning('[QNA][result][sms] decrypt ok but no digits', [
'member_num' => $memberNum,
'qna_id' => $qnaId,
]);
return;
}
Log::info('[QNA][result][sms] sending', [
'to' => $this->maskPhone($digits),
'msg_len' => mb_strlen($msg),
]);
app(SmsService::class)->send([
'from_number' => '1833-4856',
'to_number' => $digits,
'message' => $msg,
'sms_type' => 'sms',
'country' => 82,
]);
Log::info('[QNA][result][sms] sent', [
'to' => $this->maskPhone($digits),
]);
} catch (\Throwable $e) {
Log::warning('[QNA][result][sms] failed', [
'member_num' => $memberNum,
'qna_id' => $qnaId,
'err' => $e->getMessage(),
]);
}
}
}
private function categoriesByNum(): array
{
$cats = (array) config('cs_faq.categories', []);
$map = [];
foreach ($cats as $c) {
$num = (int)($c['num'] ?? 0);
if ($num > 0) $map[$num] = $c; // ['key','label','num']
}
return $map;
}
private function categoryLabel($enquiryCode): string
{
$num = (int) $enquiryCode;
$map = $this->categoriesByNum();
return $map[$num]['label'] ?? ((string)$enquiryCode ?: '-');
}
private function assertCanMemo(object $row, int $adminId): void
{
if ((string)($row->state ?? '') === 'a') {
throw ValidationException::withMessages([
'answer_content' => '업무 배정 후 메모를 남길 수 있습니다.',
]);
}
if ((int)($row->answer_admin_num ?? 0) !== $adminId) {
throw ValidationException::withMessages([
'answer_content' => '내 업무로 배정된 건만 메모를 남길 수 있습니다.',
]);
}
}
}