diff --git a/claudedocs/api/[IMPL-2026-03-18] expense-accounts-receipt-no.md b/claudedocs/api/[IMPL-2026-03-18] expense-accounts-receipt-no.md new file mode 100644 index 00000000..ff98b49c --- /dev/null +++ b/claudedocs/api/[IMPL-2026-03-18] expense-accounts-receipt-no.md @@ -0,0 +1,70 @@ +# 접대비 증빙번호(receipt_no) 자동 매핑 및 수기 입력 지원 + +## 날짜: 2026-03-18 + +## 배경 +CEO 대시보드 접대비 현황에서 "증빙 미비"로 표시되는 항목의 근본 원인: +- `expense_accounts.receipt_no`가 항상 `null`로 고정 저장됨 +- 카드 거래(바로빌) 승인번호가 전달되지 않음 +- 수기 전표 입력 시 증빙번호 입력 필드 부재 + +## 수정 파일 + +### 백엔드 (sam-api) + +| 파일 | 변경 내용 | +|------|----------| +| `app/Traits/SyncsExpenseAccounts.php` | `syncExpenseAccounts()`에 `$receiptNo` 파라미터 추가 + `resolveReceiptNo()` 메서드 신규 | +| `app/Services/JournalSyncService.php` | `saveForSource()`에 `$receiptNo` 파라미터 추가 → `syncExpenseAccounts()`에 전달 | +| `app/Services/GeneralJournalEntryService.php` | `store()`, `updateJournal()`에서 `$data['receipt_no']` → `syncExpenseAccounts()`에 전달 | +| `app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php` | `receipt_no` validation 규칙 추가 (`nullable\|string\|max:100`) | +| `app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php` | `receipt_no` validation 규칙 추가 (`nullable\|string\|max:100`) | + +### 프론트엔드 (sam-react-prod) + +| 파일 | 변경 내용 | +|------|----------| +| `src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx` | 증빙번호 입력 필드 추가 (FormField) | +| `src/components/accounting/GeneralJournalEntry/actions.ts` | `createManualJournal()`에 `receiptNo` 파라미터 추가 → `receipt_no` body 전달 | + +## 증빙번호 결정 로직 (우선순위) + +``` +1순위: 명시적 전달 ($receiptNo 파라미터) — 수기 전표에서 사용자가 직접 입력 +2순위: 바로빌 카드 승인번호 자동 조회 — source_type=barobill_card일 때 approval_num +3순위: null — 기본값 (증빙 미비로 판정됨) +``` + +## SyncsExpenseAccounts 변경 상세 + +### Before +```php +ExpenseAccount::create([ + 'receipt_no' => null, // 항상 null +]); +``` + +### After +```php +// 증빙번호 결정: 명시 전달 > 바로빌 승인번호 > null +$resolvedReceiptNo = $receiptNo ?? $this->resolveReceiptNo($entry); + +ExpenseAccount::create([ + 'receipt_no' => $resolvedReceiptNo, +]); +``` + +### resolveReceiptNo() 신규 메서드 +- `SOURCE_BAROBILL_CARD` → `source_key`에서 ID 추출 → `BarobillCardTransaction.approval_num` 조회 +- 그 외 → `null` + +## 영향 범위 +- CEO 대시보드 접대비 현황: 증빙 미비 건수 정확도 향상 +- CEO 대시보드 복리후생비 현황: 동일 트레이트 사용으로 함께 개선 +- 일반전표입력: 증빙번호 필드 추가 (UI) +- 카드사용내역 분개: 바로빌 승인번호 자동 매핑 (추가 UI 변경 없음) + +## 테스트 결과 +- 수기 전표에 증빙번호 입력 → expense_accounts.receipt_no에 저장 확인 +- 기존 미증빙 전표에 증빙번호 PUT → 증빙 미비 해소 확인 +- CEO 대시보드 접대비 현황: 증빙 미비 0건 / 고액 결제 0건 확인 diff --git a/src/components/stocks/BendingLotForm.tsx b/src/components/stocks/BendingLotForm.tsx index 35e66ba5..ad10943a 100644 --- a/src/components/stocks/BendingLotForm.tsx +++ b/src/components/stocks/BendingLotForm.tsx @@ -317,7 +317,8 @@ export function BendingLotForm() { if (result.success && result.data) { setResolvedItem(result.data); } else { - setResolveError('해당 조합에 매핑된 품목이 없습니다.'); + const code = result.data?.expected_code; + setResolveError(`해당 조합에 매핑된 품목이 없습니다.${code ? ` (${code})` : ''}`); } } }, diff --git a/src/components/stocks/StockProductionDetail.tsx b/src/components/stocks/StockProductionDetail.tsx index 0fa63a6d..92506dc1 100644 --- a/src/components/stocks/StockProductionDetail.tsx +++ b/src/components/stocks/StockProductionDetail.tsx @@ -22,10 +22,6 @@ import { } from '@/components/ui/table'; import { Package, - Pencil, - CheckCircle2, - XCircle, - Factory, ClipboardList, MessageSquare, Tag, @@ -40,7 +36,6 @@ import { getStockOrderById, updateStockOrderStatus, deleteStockOrder, - createStockProductionOrder, type StockOrder, type StockStatus, } from './actions'; @@ -182,88 +177,12 @@ export function StockProductionDetail({ orderId }: StockProductionDetailProps) { } }, [order, router, basePath]); - // 생산지시 생성 - const handleCreateProductionOrder = useCallback(async () => { - if (!order) return; - setIsProcessing(true); - try { - const result = await createStockProductionOrder(order.id); - if (result.__authError) { - toast.error('인증이 만료되었습니다.'); - return; - } - if (result.success) { - toast.success('생산지시가 생성되었습니다.'); - // 상태 갱신 - const refreshResult = await getStockOrderById(orderId); - if (refreshResult.success && refreshResult.data) { - setOrder(refreshResult.data); - } - } else { - toast.error(result.error || '생산지시 생성에 실패했습니다.'); - } - } finally { - setIsProcessing(false); - } - }, [order, orderId]); - - // 수정 이동 - const handleEdit = useCallback(() => { - router.push(`${basePath}/${orderId}?mode=edit`); - }, [router, basePath, orderId]); - // 헤더 액션 버튼 + // 재고생산: 저장 즉시 IN_PROGRESS (확정/생산지시 자동). draft/confirmed 분기 불필요. const headerActionItems = useMemo((): ActionItem[] => { if (!order) return []; const items: ActionItem[] = []; - // draft → 확정 가능 - if (order.status === 'draft') { - items.push({ - icon: CheckCircle2, - label: '확정', - onClick: () => handleStatusChange('confirmed'), - className: 'bg-blue-600 hover:bg-blue-700 text-white', - disabled: isProcessing, - }); - items.push({ - icon: Pencil, - label: '수정', - onClick: handleEdit, - variant: 'outline', - }); - } - - // confirmed → 생산지시 생성 + 수정 불가 안내 - if (order.status === 'confirmed') { - items.push({ - icon: Factory, - label: '생산지시 생성', - onClick: handleCreateProductionOrder, - className: 'bg-green-600 hover:bg-green-500 text-white', - disabled: isProcessing, - }); - items.push({ - icon: Pencil, - label: '수정', - onClick: () => toast.warning('확정 상태에서는 수정이 불가합니다.'), - variant: 'outline', - disabled: false, - className: 'opacity-50', - }); - } - - // draft/confirmed → 취소 가능 - if (order.status === 'draft' || order.status === 'confirmed') { - items.push({ - icon: XCircle, - label: '취소', - onClick: () => handleStatusChange('cancelled'), - variant: 'destructive', - disabled: isProcessing, - }); - } - // cancelled → 복원 버튼 if (order.status === 'cancelled') { items.push({ @@ -276,7 +195,7 @@ export function StockProductionDetail({ orderId }: StockProductionDetailProps) { } return items; - }, [order, isProcessing, handleStatusChange, handleEdit, handleCreateProductionOrder]); + }, [order, isProcessing, handleStatusChange]); // 상태 뱃지 — 기본 정보 카드에 이미 표시되므로 하단 바에는 미표시 const headerActions = useMemo(() => { diff --git a/src/components/stocks/actions.ts b/src/components/stocks/actions.ts index b5bbec38..089739fa 100644 --- a/src/components/stocks/actions.ts +++ b/src/components/stocks/actions.ts @@ -544,6 +544,7 @@ export interface BendingResolvedItem { item_name: string; specification: string; unit: string; + expected_code?: string; } export interface BendingLotResult { @@ -612,12 +613,34 @@ export async function resolveBendingItem( error?: string; __authError?: boolean; }> { - const result = await executeServerAction({ - url: buildApiUrl('/api/v1/bending/resolve-item', { prod, spec, length }), - errorMessage: '품목 매핑 조회에 실패했습니다.', - }); - if (result.__authError) return { success: false, __authError: true }; - return { success: result.success, data: result.data, error: result.error }; + const { serverFetch } = await import('@/lib/api/fetch-wrapper'); + const { safeResponseJson } = await import('@/lib/api/safe-json-parse'); + const url = buildApiUrl('/api/v1/bending/resolve-item', { prod, spec, length }); + const { response, error: fetchError } = await serverFetch(url, { method: 'GET', cache: 'no-store' }); + + if (fetchError) { + return { success: false, error: fetchError.message, __authError: fetchError.__authError }; + } + if (!response) { + return { success: false, error: '품목 매핑 조회에 실패했습니다.' }; + } + + const raw = await safeResponseJson(response) as Record; + + if (!response.ok || !raw.success) { + // NOT_MAPPED: error.expected_code 추출 + const errorObj = raw.error as Record | undefined; + const expectedCode = (errorObj?.expected_code as string) || undefined; + return { + success: false, + data: expectedCode ? { expected_code: expectedCode } as BendingResolvedItem : undefined, + error: (raw.message as string) || '해당 조합에 매핑된 품목이 없습니다.', + }; + } + + // 성공: data에 expected_code 포함 + const data = raw.data as BendingResolvedItem; + return { success: true, data }; } /**