821 lines
31 KiB
PHP
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' => '내 업무로 배정된 건만 메모를 남길 수 있습니다.',
|
|
]);
|
|
}
|
|
}
|
|
|
|
}
|