Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-react-prod into develop
This commit is contained in:
@@ -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건 확인
|
||||
@@ -366,7 +366,8 @@ export function BendingLotForm({ initialData, isEditMode = false }: BendingLotFo
|
||||
if (result.success && result.data) {
|
||||
setResolvedItem(result.data);
|
||||
} else {
|
||||
setResolveError('해당 조합에 매핑된 품목이 없습니다.');
|
||||
const code = result.data?.expected_code;
|
||||
setResolveError(`해당 조합에 매핑된 품목이 없습니다.${code ? ` (${code})` : ''}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<BendingResolvedItem>({
|
||||
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<string, unknown>;
|
||||
|
||||
if (!response.ok || !raw.success) {
|
||||
// NOT_MAPPED: error.expected_code 추출
|
||||
const errorObj = raw.error as Record<string, unknown> | 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user