Compare commits

...

113 Commits

Author SHA1 Message Date
3ac64d5b76 feat: [품질검사] 수주 선택 필터링 + 개소 상세 + 검사 상태 개선
- availableOrders: client_id/item_id 필터 파라미터 지원
- availableOrders: 응답에 client_id, client_name, item_id, item_name, locations(개소 상세) 추가
- show: 개소별 데이터에 거래처/모델 정보 포함
- DocumentService: fqcStatus rootNodes 기반으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
2231c9a48f feat: 제품검사 요청서 Document(EAV) 자동생성 및 동기화
- document_template_sections에 description 컬럼 추가 (마이그레이션)
- DocumentTemplateSection 모델에 description fillable 추가
- QualityDocumentService에 syncRequestDocument() 메서드 추가
  - quality_document 생성/수정/수주연결 시 요청서 Document 자동생성
  - 기본필드, 섹션 데이터, 사전고지 테이블 EAV 자동매핑
  - rendered_html 초기화 (데이터 변경 시 재캡처 트리거)
- transformToFrontend에 request_document_id 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
ff8553055c chore: [API] logging, docs, seeder 등 부수 정리
- LOGICAL_RELATIONSHIPS.md 보완
- Legacy5130Calculator 수정
- logging.php 설정 추가
- KyungdongItemSeeder 수정
- docs/INDEX.md, changes 문서 경로 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
f2eede6e3a feat: [품질관리] order_ids 영속성 + location 데이터 저장
- StoreRequest/UpdateRequest에 order_ids 검증 추가
- UpdateRequest에 locations 검증 추가 (시공규격, 변경사유, 검사데이터)
- QualityDocumentLocation에 inspection_data(JSON) fillable/cast 추가
- QualityDocumentService store()에 syncOrders 연동
- QualityDocumentService update()에 syncOrders + updateLocations 연동
- inspection_data 컬럼 추가 migration 신규

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
c5d5b5d076 feat: [문서스냅샷] Lazy Snapshot API - snapshot 엔드포인트 + resolve에 snapshot_document_id 추가
- PATCH /documents/{id}/snapshot: canEdit 체크 없이 rendered_html만 업데이트
- DocumentService::patchSnapshot() 메서드 추가
- WorkOrderService::resolveInspectionDocument()에 snapshot_document_id 반환 (상태 무관, rendered_html NULL인 문서)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
5ebf940873 fix: [문서스냅샷] UpsertRequest rendered_html 검증 추가 및 upsert() 전달 누락 수정
- UpsertRequest에 rendered_html nullable string 검증 추가
- DocumentService upsert()에서 create/update 시 rendered_html 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
293330c418 feat: 문서 rendered_html 스냅샷 저장 지원
- Document 모델 $fillable에 rendered_html 추가
- DocumentService create/update에서 rendered_html 저장
- StoreRequest/UpdateRequest에 rendered_html 검증 추가
- WorkOrderService 검사문서/작업일지 생성 시 rendered_html 전달

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
a845f52fc0 fix: [생산지시] withCount에서도 보조 공정(재고생산) WO 제외
- 목록 조회 시 work_orders_count에서 is_auxiliary WO 제외
- whereNotNull(process_id) + options->is_auxiliary 조건 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
0f26ea546a feat: [품질관리] 수주선택 API에 발주처(client_name) 필드 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
3600c7b12b fix: [품질관리] 수주선택 모달 납품일 포맷 및 개소 수 수정
- delivery_date: ISO 타임스탬프 → Y-m-d 포맷으로 변환
- location_count: order_items 수 → order_nodes 루트 노드(개소) 수로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
a6e29bc1f3 feat: [품질관리] 백엔드 API 구현 - 품질관리서 + 실적신고
- 품질관리서(quality_documents) CRUD API 14개 엔드포인트
- 실적신고(performance_reports) 관리 API 6개 엔드포인트
- DB 마이그레이션 4개 테이블 (quality_documents, quality_document_orders, quality_document_locations, performance_reports)
- 모델 4개 + 서비스 2개 + 컨트롤러 2개 + FormRequest 4개
- stats() ambiguous column 버그 수정 (JOIN 시 테이블 접두사 추가)
- missing() status_code 컬럼명/값 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
0aa0a8592d feat: [생산지시] 재고생산 보조 공정 일반 워크플로우에서 분리
- Process P-004 options에 is_auxiliary 플래그 도입
- WO 생성 시 Process의 is_auxiliary를 WO options에 자동 복사
- ProductionOrderService: 보조 공정 WO를 공정 진행 현황에서 제외
- WorkOrderService: 보조 공정 WO의 상태 변경이 수주 상태에 영향 주지 않도록 처리
  - syncOrderStatus(): 보조 공정이면 스킵
  - autoStartWorkOrderOnMaterialInput(): WO는 진행중 전환하되 수주 상태는 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
38c2402771 fix: [생산지시] 공정 진행 현황 WO 필터링 + BOM 파싱 수정
- 공정 진행 현황: process_id=null인 구매품/서비스 WO 제외 (withCount, 목록/상세 모두)
- extractBomProcessGroups: bom_result.items[] 구조에 맞게 파싱 수정
  - process_name → process_group 키 사용
  - 품목 필드 매핑 수정 (item_id, specification, unit, quantity, unit_price, total_price, node_name)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
59d13eeb9f fix: [생산지시] 날짜포맷·개소수·자재투입 시 자동 상태전환
- ProductionOrderService: production_ordered_at를 Y-m-d 포맷으로 변환
- ProductionOrderService: withCount('nodes')로 개소수(node_count) 응답 추가
- WorkOrderService: autoStartWorkOrderOnMaterialInput() 신규 메서드
  - 자재투입 시 WO가 unassigned/pending/waiting이면 in_progress로 자동 전환
  - syncOrderStatus()로 Order도 IN_PRODUCTION 동기화
- Swagger: node_count 필드 문서화, 날짜 포맷 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
2df8ecf765 feat: [생산지시] 전용 API 엔드포인트 신규 생성
- ProductionOrderService: 목록(index), 통계(stats), 상세(show) 구현
  - Order 기반 생산지시 대상 필터링 (IN_PROGRESS~SHIPPED)
  - workOrderProgress 가공 필드 (total/completed/inProgress)
  - production_ordered_at (첫 WorkOrder created_at 기반)
  - BOM 공정 분류 추출 (order_nodes.options.bom_result)
- ProductionOrderController: FormRequest + ApiResponse 패턴
- ProductionOrderIndexRequest: search, production_status, sort, pagination 검증
- ProductionOrderApi.php: Swagger 문서 (목록/통계/상세)
- production.php: GET /production-orders, /stats, /{orderId} 라우트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:28:53 +09:00
김보곤
ad93743bdc feat: [approval] 연차사용촉진 통지서 1차/2차 양식 마이그레이션 추가
- leave_promotion_1st: 연차사용촉진 통지서 (1차) - hr 카테고리
- leave_promotion_2nd: 연차사용촉진 통지서 (2차) - hr 카테고리
2026-03-07 00:28:59 +09:00
김보곤
449fce1d2b feat: [approval] 공문서 양식 마이그레이션 추가 2026-03-06 23:38:56 +09:00
김보곤
9d4143a4dc feat: [approval] 견적서 양식 마이그레이션 추가 2026-03-06 23:21:50 +09:00
김보곤
c5a0115e01 feat: [approval] 이사회의사록 양식 데이터 마이그레이션 추가 2026-03-06 23:00:55 +09:00
김보곤
eb28b577e0 feat: [approval] 위임장 양식 데이터 마이그레이션 추가
- 전체 테넌트에 delegation 양식 레코드 자동 삽입
2026-03-06 22:41:31 +09:00
김보곤
22160e5904 feat: [menu] 경조사비관리 메뉴 추가 마이그레이션
- 각 테넌트의 부가세관리 메뉴 하위에 경조사비관리 메뉴 자동 추가
- 중복 방지 (이미 존재하면 skip)
2026-03-06 21:44:52 +09:00
김보곤
0ea5fa5eb9 feat: [database] 경조사비 관리 테이블 생성
- condolence_expenses 테이블: 거래처 경조사비 관리대장
- 경조사일자, 지출일자, 거래처명, 내역, 구분(축의/부조)
- 부조금(여부/지출방법/금액), 선물(여부/종류/금액), 총금액
2026-03-06 21:39:02 +09:00
김보곤
58fedb0d43 feat: [approvals] 사용인감계 양식 데이터 마이그레이션 추가
- 모든 테넌트에 seal_usage 양식 자동 등록
2026-03-06 20:51:38 +09:00
김보곤
56e7164243 feat: [departments] options JSON 컬럼 추가
- 조직도 숨기기 등 확장 속성 저장용
2026-03-06 20:24:56 +09:00
김보곤
a67c5d9fca feat: [menu] menu_favorites 테이블 마이그레이션 추가
- tenant_id, user_id, menu_id, sort_order 컬럼
- unique 제약: (tenant_id, user_id, menu_id)
- FK cascade delete: users, menus
2026-03-06 14:35:14 +09:00
유병철
816c25a631 fix: [finance] 일반전표 목록 source 필드 및 페이지네이션 구조 수정
- deposits/withdrawals 조회 시 source를 항상 'linked'로 고정
- 페이지네이션 meta 래핑 제거하여 플랫 구조로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:26:58 +09:00
유병철
12d172e4c3 feat: [finance] 계정과목 및 일반전표 API 추가
- AccountCode 모델/서비스/컨트롤러 구현
- JournalEntry, JournalEntryLine 모델 구현
- GeneralJournalEntry 서비스/컨트롤러 구현
- FormRequest 검증 클래스 추가
- finance 라우트 등록
- i18n 메시지 키 추가 (message.php, error.php)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:10:45 +09:00
유병철
be9c1baa34 fix: [receivables] 상위 거래처 집계에서 soft delete 레코드 제외
- orders, deposits, bills 서브쿼리에 whereNull('deleted_at') 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:59:16 +09:00
유병철
a7973bb555 feat: [loan] 상품권 summary에 접대비 해당 집계 추가
- expense_accounts 테이블에서 접대비(상품권) 건수/금액 조회
- entertainment_count, entertainment_amount 응답 필드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:37:57 +09:00
김보곤
96def0d71e feat: [approval] 사직서 양식 마이그레이션 추가 2026-03-06 00:13:18 +09:00
김보곤
846ced3ead feat: [approval] 위촉증명서 양식 데이터 마이그레이션 2026-03-05 23:57:45 +09:00
김보곤
0f25a5d4e1 feat: [approval] 경력증명서 양식 데이터 마이그레이션 2026-03-05 23:42:16 +09:00
유병철
c57e768b87 fix: [loan] 상품권 접대비 연동 시 receipt_no에 시리얼번호 매핑
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 22:12:46 +09:00
유병철
7fe856f3b7 fix: [loan] 상품권 카테고리는 상태 무관하게 수정/삭제 가능하도록 변경
- isEditable(): 상품권이면 상태와 무관하게 수정 허용
- isDeletable(): 상품권이면 상태와 무관하게 삭제 허용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:42:00 +09:00
유병철
652ac3d1ec fix: [loan] dashboard summary/목록에서도 used/disposed 상품권 제외
- dashboard summary 쿼리에 excludeUsedGiftCert 조건 적용
- 가지급금 목록 쿼리에도 동일 조건 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:39:17 +09:00
유병철
03f86f375e fix: [loan] 상품권 store 시 접대비 연동 + 카테고리 집계에서 사용/폐기 제외
- store()에서도 상품권 접대비 자동 연동 호출
- getCategoryBreakdown: used/disposed 상품권은 가지급금 집계에서 제외

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:32:36 +09:00
유병철
31d2f08dd8 feat: [loan] 상품권 접대비 자동 연동 기능 추가
- ExpenseAccount: loan_id 필드 + SUB_TYPE_GIFT_CERTIFICATE 상수 추가
- LoanService: 상품권 used+접대비해당 시 expense_accounts 자동 upsert/삭제
- 마이그레이션: expense_accounts에 loan_id 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:22:44 +09:00
유병철
8c9f2fcfb5 feat: [bill,loan] 어음 V8 확장 필드 및 가지급금 상품권 카테고리 지원
- Bill 모델: V8 확장 필드 54개 추가 (증권종류, 할인, 배서, 추심, 개서, 부도 등)
- Bill 상태: 수취/발행 어음·수표별 세분화된 상태 체계
- BillService: assignV8Fields/syncInstallments 헬퍼 추출, instrument_type/medium 필터
- BillInstallment: type/counterparty 필드 추가
- Loan 모델: holding/used/disposed 상태 + metadata(JSON) 필드 추가
- LoanService: 상품권 카테고리 지원 (summary 상태별 집계, store 기본상태 holding)
- FormRequest: V8 확장 필드 검증 규칙 추가
- 마이그레이션: bills V8 필드 + loans metadata 컬럼

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:45:54 +09:00
김보곤
f41605ca73 feat: [approval] 재직증명서 양식 마이그레이션 추가
- approval_forms 테이블에 employment_cert 폼 삽입
2026-03-05 18:54:25 +09:00
김보곤
66d1004bc2 feat: [rd] CM송 저장 테이블 마이그레이션 추가
- cm_songs 테이블: tenant_id, user_id, company_name, industry, lyrics, audio_path, options
2026-03-05 14:36:47 +09:00
김보곤
ce1f91074e feat: [approval] approvals 테이블에 rejection_history JSON 컬럼 추가 2026-03-05 13:50:46 +09:00
김보곤
558a393c85 feat: [approval] approvals 테이블에 resubmit_count 컬럼 추가 2026-03-05 13:06:31 +09:00
김보곤
ac72487eff feat: [approval] approvals 테이블에 drafter_read_at 컬럼 추가
- 기안자가 완료 결과를 확인했는지 추적하는 타임스탬프
- 완료함 미읽음 뱃지 기능 지원
2026-03-05 11:37:31 +09:00
3d4dd9f252 chore: [infra] Slack 알림 채널 분리 — product_infra → deploy_api
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:32:16 +09:00
2507dcf142 chore: [docs] 모델 관계 문서 갱신 + summary 플레이스홀더 API 추가
- LOGICAL_RELATIONSHIPS.md: 최신 모델 관계 반영 (inspections, shipment dispatches 등)
- stats.php: production/construction/unshipped/attendance summary 플레이스홀더

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:24 +09:00
9b8cdfa2a5 refactor: [core] 모델 스코프 적용 — Tenant::active() 사용 및 코드 규칙 추가
- RecordStorageUsage: where 하드코딩 → Tenant::active() 스코프
- CLAUDE.md: 쿼리 수정 시 모델 스코프 우선 규칙 명시

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:10 +09:00
7432fb16aa feat: [production] 자재투입 replace 모드 지원
- registerMaterialInputForItem에 replace 파라미터 추가
- 기존 투입 교체 방식 선택 가능

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:07 +09:00
d4f21f06d6 refactor: [production] 셔터박스 prefix — isStandard 파라미터 제거
- CF/CL/CP/CB 품목이 모든 길이에 등록되어 boxSize 무관하게 적용
- resolveShutterBoxPrefix()에서 불필요한 isStandard 분기 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:04 +09:00
1f7f45ee60 feat: [process] 공정단계 options 컬럼 추가 — 검사 설정/범위 지원
- ProcessStep 모델에 options JSON 컬럼 추가 (fillable, cast)
- Store/UpdateProcessStepRequest에 inspection_setting, inspection_scope 검증 규칙
- process_steps 테이블 마이그레이션

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:01:01 +09:00
cd847e01a0 feat: [approval] Document ↔ Approval 브릿지 연동 (Phase 4.2)
- Approval 모델에 linkable morphTo 관계 추가
- DocumentService: 상신 시 Approval 자동 생성 + approval_steps 변환
- ApprovalService: 승인/반려/회수 시 Document 상태 동기화
- approvals 테이블에 linkable_type, linkable_id 컬럼 마이그레이션

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 11:00:53 +09:00
ef7d9fae24 fix: [production] 절곡 검사 products 배열 FormRequest 검증 규칙 추가
- StoreItemInspectionRequest에 inspection_data.products 검증 규칙 누락으로 validated()에서 제거되던 버그 수정
- products.*.id, bendingStatus, lengthMeasured, widthMeasured, gapPoints 규칙 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
1a8bb46137 feat: [outbound] 배차차량 관리 API — CRUD + options JSON 정책
- VehicleDispatchService: index(검색/필터/페이지네이션), stats(선불/착불/합계), show, update
- VehicleDispatchController + VehicleDispatchUpdateRequest
- options JSON 컬럼 추가 (dispatch_no, status, freight_cost_type, supply_amount, vat, total_amount, writer)
- ShipmentService.syncDispatches에 options 필드 지원 추가
- inventory.php에 vehicle-dispatches 라우트 4개 등록

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
897511cb55 fix: [production] 절곡 검사 데이터 전체 item 복제 + bending EAV 변환
- storeItemInspection: bending/bending_wip 시 동일 작업지시 모든 item에 복제 저장
- transformBendingProductsToRecords: products 배열 → bending EAV 레코드 변환
- getMaterialInputLots: 품목코드별 그룹핑으로 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
5ee97c2d74 fix: [production] 자재투입 bom_group_key 개별 저장 — 동일 자재 다중 BOM 그룹 지원
- bom_group_key 컬럼 추가 마이그레이션 (work_order_material_inputs)
- WorkOrderMaterialInput 모델 fillable에 bom_group_key 추가
- MaterialInputForItemRequest에 bom_group_key 검증 + replace 옵션 추가
- WorkOrderService.getMaterialsForItem: stock_lot_id+bom_group_key 복합키 기투입 조회 (하위호환)
- WorkOrderService.registerMaterialInputForItem: bom_group_key 저장 + replace 모드 (기존 삭제→재등록)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
8518621432 feat: [shipment] 배차정보 다중 행 시스템 — shipment_vehicle_dispatches 테이블 추가
- 신규 마이그레이션: shipment_vehicle_dispatches 테이블 (seq, logistics_company, arrival_datetime, tonnage, vehicle_no, driver_contact, remarks)
- 신규 모델: ShipmentVehicleDispatch (ShipmentItem 패턴 복제)
- Shipment 모델: vehicleDispatches() HasMany 관계 추가
- ShipmentService: syncDispatches() 추가, store/update/delete/show/index에서 연동
- FormRequest: Store/Update에 vehicle_dispatches 배열 검증 규칙 추가
- delivery_method 검증에 확장 옵션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
fc537898fc fix: [production] 자재투입 모달 개선 — lot_managed 필터링, BOM 그룹키, 셔터박스 순서
- getMaterialsForItem: lot_managed===false 품목 자재투입 목록에서 제외 (L-Bar, 보강평철)
- getMaterialsForItem: bom_group_key 필드 추가 (category+partType 기반 고유키)
- BendingInfoBuilder: shutterPartTypes에서 top_cover/fin_cover 제거 (별도 생성과 중복)
- BendingInfoBuilder: 셔터박스 루프 순서 파트→길이로 변경 (작업일지 순서 일치)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:54 +09:00
유병철
fefd129795 refactor: [daily-report] 엑셀 내보내기 화면 데이터 기반으로 리팩토링
- DailyReportService: exportData를 dailyAccounts() 재사용 구조로 변경
- DailyReportExport: 헤더 라벨 전월이월/당월입금/당월출금/잔액으로 수정
- 화면 합계와 엑셀 합계 일치하도록 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:44:29 +09:00
유병철
1b2363d661 feat: [daily-report] 엑셀 내보내기에 어음/외상매출채권 현황 섹션 추가
- DailyReportExport: 어음 현황 테이블 + 합계 + 스타일링 추가
- DailyReportService: exportData에 noteReceivables 데이터 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:06:48 +09:00
유병철
f1a3e0f164 fix: [dashboard-ceo] 공정명 컬럼 및 근태 부서 조인 수정
- processes 테이블: p.name → p.process_name 컬럼명 수정
- 근태: users.department_id → tenant_user_profiles 경유 조인으로 변경
- 직급: users.position → tup.position_key 컬럼 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:41:54 +09:00
유병철
e8da2ea1b1 feat: [dashboard-ceo] CEO 대시보드 섹션별 API 및 일일보고서 엑셀 추가
- DashboardCeoController/Service: 6개 섹션 API 신규 (매출/매입/생산/미출고/시공/근태)
- DailyReportController/Service: 엑셀 다운로드 API 추가 (GET /daily-report/export)
- 라우트: dashboard 하위 6개 + daily-report/export 엔드포인트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 09:35:30 +09:00
김보곤
e0bb19a017 fix: [storage] RecordStorageUsage 명령어 tenants 테이블 컬럼명 오류 수정
- Tenant::where('status', 'active') → Tenant::active() 스코프 사용
- tenants 테이블에 status 컬럼 없음, tenant_st_code 사용
2026-03-05 09:16:19 +09:00
유병철
74a60e06bc feat: [calendar,vat] 캘린더 CRUD 및 부가세 상세 조회 API 추가
- CalendarController/Service: 일정 등록/수정/삭제 API 추가
- VatController/Service: getDetail() 상세 조회 (요약, 참조테이블, 미발행 목록, 신고기간 옵션)
- 라우트: POST/PUT/DELETE /calendar/schedules, GET /vat/detail 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:33:04 +09:00
유병철
2f3ec13b24 fix: [entertainment] 분기 사용액 조회에 날짜 필터 적용
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:00:04 +09:00
유병철
94b96e22f6 feat: [entertainment] 접대비 상세 조회 날짜 필터 파라미터 추가
- EntertainmentController: detail에 start_date/end_date 파라미터 전달
- EntertainmentService: getDetail 리스크/사용자분포/거래내역에 날짜 필터 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:32:16 +09:00
유병철
a173a5a4fc fix: [loan] getCategoryBreakdown SQL alias 충돌 수정
- outstanding_amount → cat_outstanding alias 변경 (Loan accessor 충돌 방지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:30:31 +09:00
유병철
66da2972fa feat: [entertainment,loan] 접대비 상세 조회 API 및 가지급금 날짜 필터 추가
- EntertainmentController/Service: getDetail() 상세 조회 API 추가 (손금한도, 월별추이, 사용자분포, 거래내역, 분기현황)
- EntertainmentService: 수입금액별 추가한도 계산(세법 기준), 거래건별 리스크 감지
- LoanController/Service: dashboard에 start_date/end_date 파라미터 지원
- LoanService: getCategoryBreakdown 날짜 필터 적용, 목록 limit 10→50 확대
- 라우트: GET /entertainment/detail 엔드포인트 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:11:37 +09:00
김보곤
282bf26eec feat: [approval] 지출결의서 body_template 고도화
- 참조 문서 기반으로 정형 양식 HTML 리디자인
- 지출형식/세금계산서 체크박스, 기본정보, 8열 내역 테이블, 합계, 첨부 섹션 포함
2026-03-04 14:52:19 +09:00
김보곤
b86af29cc9 feat: [approval] body_template 컬럼 추가 및 지출결의서 양식 등록
- approval_forms 테이블에 body_template TEXT 컬럼 추가
- 지출결의서(expense) 양식 데이터 등록 (HTML 테이블 본문 템플릿 포함)
2026-03-04 14:52:19 +09:00
유병철
f665d3aea8 fix: [entertainment,welfare] 바로빌 조인 컬럼명 및 심야 시간 파싱 수정
- approval_no → approval_num 컬럼명 수정
- use_time 심야 판별: HOUR() → SUBSTRING 문자열 파싱으로 변경
- whereNotNull('bct.use_time') 조건 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:28:00 +09:00
유병철
e637e3d1f7 feat: [dashboard] D1.7 기획서 기반 리스크 감지형 서비스 리팩토링
- EntertainmentService: 리스크 감지형 전환 (주말/심야, 기피업종, 고액결제, 증빙미비)
- WelfareService: 리스크 감지형 전환 (비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과)
- ReceivablesService: summary를 cards + check_points 구조로 개선 (누적/당월 미수금, Top3 거래처)
- LoanService: getCategoryBreakdown 전체 대상으로 집계 조건 변경

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:21:05 +09:00
김보곤
7cf70dbcaa fix: [address] 주소 필드 255자 → 500자 확장
- DB 마이그레이션: clients, tenants, site_briefings, sites 테이블 address 컬럼 varchar(500)
- FormRequest 8개 파일 max:255 → max:500 변경
2026-03-04 11:29:08 +09:00
김보곤
76192fc177 fix: [cards] cards/stats → card-transactions/dashboard 리다이렉트 추가 2026-03-04 11:10:01 +09:00
김보곤
da04b84bb5 fix: [models] User 모델 import 누락/오류 수정
- Loan.php: User import 누락 → App\Models\Members\User 추가
- TodayIssue.php: App\Models\Users\User → App\Models\Members\User 수정
- Tenants 네임스페이스에서 User::class가 App\Models\Tenants\User로 잘못 해석되는 문제 해결
2026-03-04 11:06:11 +09:00
유병철
1deeafc4de feat: [expense,loan] 대시보드 상세 필터 및 가지급금 카테고리 분류
- ExpectedExpenseController/Service: dashboardDetail에 start_date/end_date/search 파라미터 추가
- Loan 모델: category 상수 및 라벨 정의 (카드/경조사/상품권/접대비)
- LoanService: dashboard에 category_breakdown 집계 추가
- 마이그레이션: loans 테이블 category 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:42:53 +09:00
김보곤
4f3467c3b0 feat: [barobill] 바로빌 연동 API 엔드포인트 추가
- GET /api/v1/barobill/status — 연동 현황 조회
- POST /api/v1/barobill/login — 로그인 정보 등록
- POST /api/v1/barobill/signup — 회원가입 정보 등록
- GET /api/v1/barobill/bank-service-url — 은행 서비스 URL
- GET /api/v1/barobill/account-link-url — 계좌 연동 URL
- GET /api/v1/barobill/card-link-url — 카드 연동 URL
- GET /api/v1/barobill/certificate-url — 공인인증서 URL
2026-03-04 09:03:55 +09:00
김보곤
e9fd75fa74 feat: [inspection] 캘린더 스케줄 조회 API 추가
- GET /api/v1/inspections/calendar 엔드포인트 추가
- year, month, inspector, status 파라미터 지원
- React 프론트엔드 CalendarItemApi 형식에 맞춰 응답
2026-03-04 08:33:32 +09:00
김보곤
23c6cf6919 feat: [hr] Leave 모델 확장 + 결재양식 마이그레이션 추가
- Leave 타입 6개 추가: business_trip, remote, field_work, early_leave, late_reason, absent_reason
- 그룹 상수 추가: VACATION_TYPES, ATTENDANCE_REQUEST_TYPES, REASON_REPORT_TYPES
- FORM_CODE_MAP: 유형 → 결재양식코드 매핑 상수
- ATTENDANCE_STATUS_MAP: 유형 → 근태상태 매핑 상수
- 결재양식 2개 추가: attendance_request(근태신청), reason_report(사유서)
2026-03-03 23:52:13 +09:00
유병철
42443349dc feat: [stock,client,status-board] 날짜 필터 및 조건 보완
- StockController/StockService: 입출고 이력 기반 날짜 범위 필터 추가
- ClientService: 등록일 기간 필터(start_date/end_date) 추가
- StatusBoardService: 부실채권 현황에 is_active 조건 추가 (목록 페이지 일치)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:53:30 +09:00
유병철
ad27090bfc feat: [daily-report] 자금현황 카드용 필드 추가
- 미수금 잔액(receivable_balance) 계산 로직 구현
- 미지급금 잔액(payable_balance) 계산 로직 구현
- 당월 예상 지출(monthly_expense_total) 계산 로직 구현
- summary API 응답에 자금현황 3개 필드 포함

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:06:09 +09:00
유병철
b7465becab feat: [approval] 결재 수신함 날짜 범위 필터 추가
- InboxIndexRequest에 start_date/end_date 검증 룰 추가
- ApprovalService.inbox()에 created_at 날짜 범위 필터 구현

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 20:02:07 +09:00
유병철
83a774572a feat: [today-issue] 날짜 기반 이전 이슈 조회 기능 추가
- TodayIssueController에 date 파라미터(YYYY-MM-DD) 추가
- TodayIssueService.summary()에 날짜 기반 필터링 로직 구현
- 이전 이슈 조회 시 만료(active) 필터 무시하여 과거 데이터 조회 가능

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:48:30 +09:00
김보곤
da1142af62 feat: [ai-quotation] 제조 견적서 마이그레이션 추가
- ai_quotations: quote_mode, quote_number, product_category 컬럼 추가
- ai_quotation_items: specification, unit, quantity, unit_price, total_price, item_category, floor_code 컬럼 추가
- ai_quote_price_tables 테이블 신규 생성
2026-03-03 15:57:19 +09:00
김보곤
b3c7d08b2c feat: [hr] 사업소득자 임금대장 display_name/business_reg_number 컬럼 추가
- user_id nullable 변경 (직접 입력 대상자 지원)
- display_name, business_reg_number 컬럼 추가
- 기존 데이터 earner 프로필에서 자동 채움
2026-03-03 14:33:03 +09:00
7e309e4057 fix: [deploy] 배포 시 .env 권한 640 보장 추가
- Stage/Production 배포 스크립트에 chmod 640 추가
- vi 편집으로 인한 .env 권한 변경(600) 방지
- 2026-03-03 장애 재발 방지 (PHP-FPM이 .env 읽기 실패 → 500)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:51:07 +09:00
김보곤
f79d008777 chore: [ai] Gemini 모델 gemini-2.0-flash → gemini-2.5-flash 마이그레이션
- config/services.php fallback 기본값 변경
- AiReportService fallback 기본값 변경
2026-03-03 08:09:08 +09:00
김보곤
abe04607e4 feat: [rd] AI 견적 엔진 테이블 생성 + 모듈 카탈로그 시더
- ai_quotation_modules: SAM 모듈 카탈로그 (18개 모듈)
- ai_quotations: AI 견적 요청/결과
- ai_quotation_items: AI 추천 모듈 목록
- AiQuotationModuleSeeder: customer-pricing 기반 초기 데이터
2026-03-02 17:43:36 +09:00
김보곤
3ca161e9e2 feat: [roadmap] 중장기 계획 테이블 마이그레이션 추가
- admin_roadmap_plans: 계획 테이블 (제목, 카테고리, 상태, Phase, 진행률 등)
- admin_roadmap_milestones: 마일스톤 테이블 (plan_id FK, 상태, 예정일 등)
2026-03-02 15:50:05 +09:00
김보곤
587bdf5d80 feat: [interview] 마스터 질문 데이터 시드 마이그레이션 추가
- 8개 도메인, 16개 템플릿, 80개 마스터 질문 INSERT
- idempotent 처리: 이미 도메인 카테고리 존재 시 스킵
- Jenkins 자동 배포로 운영서버 데이터 반영 목적
2026-02-28 22:05:59 +09:00
김보곤
f2d36c3616 feat: [interview] 카테고리 계층 구조 parent_id 마이그레이션 추가
- interview_categories 테이블에 parent_id 컬럼 추가
- self-referencing FK, nullOnDelete
2026-02-28 21:23:19 +09:00
김보곤
397d50de1f feat: [interview] 인터뷰 시나리오 고도화 마이그레이션
- interview_projects 테이블 신규 (회사별 프로젝트)
- interview_attachments 테이블 신규 (첨부파일 + AI 분석)
- interview_knowledge 테이블 신규 (AI 추출 지식)
- interview_categories에 project_id, domain 컬럼 추가
- interview_questions에 ai_hint, expected_format, depends_on, domain 추가
- interview_answers에 answer_data, attachments JSON 추가
- interview_sessions에 project_id, session_type, voice_recording_id 추가
2026-02-28 20:02:33 +09:00
김보곤
73949b1282 feat: [document] 블록 빌더 지원 마이그레이션 추가
- document_templates: builder_type, schema, page_config 컬럼 추가
- documents: data JSON, rendered_html, pdf_path 컬럼 추가
2026-02-28 19:31:46 +09:00
김보곤
e40555ad37 feat: [leaves] 휴가-결재 연동을 위한 DB 변경
- leaves 테이블에 approval_id 컬럼 추가 (마이그레이션)
- 휴가신청 결재 양식(approval_forms) 등록 (마이그레이션)
- Leave 모델 fillable에 approval_id 추가
2026-02-28 15:54:34 +09:00
김보곤
79da7a6da7 feat: [equipment] 다중 점검주기 + 부 담당자 DB 스키마 추가
- equipments: sub_manager_id 컬럼 추가
- equipment_inspection_templates: inspection_cycle 컬럼 + 유니크 변경
- equipment_inspections: inspection_cycle 컬럼 + 유니크 변경
2026-02-28 12:37:26 +09:00
김보곤
b04d407fdb feat: [approval] Phase 2 마이그레이션 추가
- approval_steps: parallel_group, acted_by, approval_type 컬럼 추가
- approvals: recall_reason, parent_doc_id 컬럼 추가
- approval_delegations 테이블 생성 (위임/대결)
2026-02-27 23:41:34 +09:00
김보곤
c32d68f069 feat: [approval] 결재관리 Phase 1 마이그레이션
- approvals 테이블: line_id, body, is_urgent, department_id 컬럼 추가
- approval_steps 테이블: approver_name, approver_department, approver_position 스냅샷 컬럼 추가
2026-02-27 23:26:40 +09:00
afc1aa72a8 feat: [inspection] 절곡 검사 마감유형(S1/S2/S3) 프로파일 분리 — 5130 레거시 기준
- BENDING_GAP_PROFILES를 S1/S2/S3 + common 계층 구조로 재구성
  - S1(KSS01): 벽면 4pt, 측면 6pt, 하단 1pt
  - S2(KSS02): 벽면 3pt, 측면 5pt, 하단 1pt
  - S3(KWE01/KSE01+SUS): 벽면 5pt, 측면 7pt, 하단 2pt
- resolveFinishingType() 신규: product_code → 마감유형 자동 판별
- buildBendingInspectionItems()에 마감유형별 프로파일 적용
- getInspectionConfig() 응답에 finishing_type 필드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:18:09 +09:00
f970b6bf4b feat: [inspection] Phase 3 절곡 검사 동적 구현 — inspection-config API + 트랜잭션 보강
- inspection-config API 신규: GET /work-orders/{id}/inspection-config
  - 공정 자동 판별 (resolveInspectionProcessType)
  - bending_info 기반 구성품 목록 + gap_points 반환
  - BENDING_GAP_PROFILES 상수 (6개 구성품 간격 기준치)
- createInspectionDocument 트랜잭션 보강
  - DB::transaction() + lockForUpdate() 적용
  - 동시 생성 race condition 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:18:09 +09:00
e6c02292d2 feat: [quote/quality] Phase 2B 견적 product_code 자동추출 + inspections work_order_id FK
- QuoteService: extractProductCodeFromInputs() 추가, store/update에서 자동 추출
- BackfillQuoteProductCodeCommand: 기존 quotes 25건 product_code 보정
- inspections 테이블에 work_order_id FK 마이그레이션 (nullable, nullOnDelete)
- Inspection↔WorkOrder 양방향 관계 추가
- InspectionService: store/show/index에 work_order_id 처리 + transformToFrontend
- InspectionStoreRequest: work_order_id 검증 규칙 추가
2026-02-27 23:18:09 +09:00
9e84fa04a6 fix: [production] product_code 전파 버그 수정
- OrderService::createProductionOrder에 product_code/product_name 추가
- WorkOrderService::store 수주복사 경로에 product_code/product_name 추가
- order_nodes.options → work_order_items.options 전파 누락 해결
2026-02-27 23:18:09 +09:00
김보곤
ff8b37670e feat: [hr] 사업소득자 임금대장 테이블 마이그레이션 추가
- business_income_payments 테이블 생성
- 소득세(3%)/지방소득세(0.3%) 고정세율 구조
- (tenant_id, user_id, pay_year, pay_month) 유니크 제약
2026-02-27 20:21:59 +09:00
김보곤
75408d925f feat: [esign] esign_contracts 테이블에 completion_template_name 컬럼 추가
- 완료 알림톡 템플릿명을 저장하기 위한 nullable string 컬럼
2026-02-27 16:28:57 +09:00
김보곤
dd11f780b4 feat: [payroll] 근로소득세 간이세액표 DB 테이블 및 시더 추가
- income_tax_brackets 테이블 마이그레이션 생성
- 2024년 국세청 간이세액표 데이터 시더 (7,117건)
- salary_from/salary_to(천원), family_count(1~11), tax_amount(원)
2026-02-27 13:58:39 +09:00
김보곤
c94bef1dae feat: [hr] 사업소득자관리 worker_type 컬럼 추가
- tenant_user_profiles 테이블에 worker_type 컬럼 추가 (employee/business_income)
- TenantUserProfile 모델 fillable에 worker_type 추가
2026-02-27 13:46:42 +09:00
f53f04de65 fix: [cicd] 배포 시 storage/bootstrap 권한 설정 추가
- mkdir 후 www-data:webservice 소유권 + 775 권한 설정
- Stage/Production 배포 모두 적용
- 원인: PHP-FPM(www-data)이 storage 쓰기 불가 → 500 에러
2026-02-27 10:43:11 +09:00
김보곤
96c4f71607 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-02-27 10:08:06 +09:00
김보곤
0d1d056b13 feat: [payroll] payrolls 테이블에 long_term_care 컬럼 추가 2026-02-27 10:06:25 +09:00
ac7279606d chore: 운영 배포 승인 단계 비활성화 (개발 단계)
- Production Approval stage 주석처리
- 런칭 후 다시 활성화 예정
- 배포 흐름: main push → Stage → Production (승인 없이 자동)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:32:13 +09:00
김보곤
fcb377a40c feat: [attendance] attendance_requests 테이블 마이그레이션 추가
- 근태 승인 워크플로우용 신청 테이블
- tenant_id, user_id, request_type, start_date, end_date, status 등
2026-02-26 20:56:41 +09:00
김보곤
1a2350db7d feat: [calendar] 달력 일정 관리 API 구현
- GET /api/v1/calendar-schedules — 연도별 일정 목록 조회
- GET /api/v1/calendar-schedules/stats — 통계 조회
- GET /api/v1/calendar-schedules/{id} — 단건 조회
- POST /api/v1/calendar-schedules — 등록
- PUT /api/v1/calendar-schedules/{id} — 수정
- DELETE /api/v1/calendar-schedules/{id} — 삭제
- POST /api/v1/calendar-schedules/bulk — 대량 등록
2026-02-26 14:29:12 +09:00
김보곤
0ff731ab09 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-api into develop 2026-02-25 21:35:53 +09:00
0345ddcce3 fix: 세션 만료 예외를 슬랙 알림에서 제외
- '회원정보 정보 없음' AuthenticationException은 API Key 검증 통과 후 발생하므로 세션 만료 정상 케이스
- IP 기반 필터링(EXCEPTION_IGNORED_IPS) 대신 예외 자체를 무조건 제외하도록 단순화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:14:48 +09:00
김보곤
a6e547f40d feat: [equipment] files 테이블에 GCS 컬럼 추가
- gcs_object_name, gcs_uri 컬럼 추가
- 설비 사진 멀티 업로드 기능 지원
2026-02-25 20:14:41 +09:00
김보곤
e819635ea6 feat: [equipment] 설비관리 테이블 마이그레이션 6개 생성
- equipments (설비 마스터)
- equipment_inspection_templates (점검항목 템플릿)
- equipment_inspections (월간 점검 헤더)
- equipment_inspection_details (일자별 점검 결과)
- equipment_repairs (수리이력)
- equipment_process (설비-공정 피봇)
2026-02-25 19:39:52 +09:00
217 changed files with 13019 additions and 592 deletions

View File

@@ -54,4 +54,4 @@ ## 관련 파일
- `api/app/Services/ComprehensiveAnalysisService.php`
- `api/database/seeders/ComprehensiveAnalysisSeeder.php`
- `docs/plans/react-mock-remaining-tasks.md`
- `docs/dev/dev_plans/react-mock-remaining-tasks.md`

View File

@@ -15,7 +15,7 @@ ## Phase 구성
- Phase 5: MNG 관리자 패널 (4항목) — mng/ /system/alerts
## 핵심 파일
- 계획 문서: docs/plans/db-backup-system-plan.md
- 계획 문서: docs/dev/dev_plans/db-backup-system-plan.md
- 개발서버: 114.203.209.83 (SSH: hskwon)
- DB: sam (메인) + sam_stat (통계)
- Slack 웹훅: api/.env → LOG_SLACK_WEBHOOK_URL (이미 설정됨)

View File

@@ -16,7 +16,7 @@ ### 생성된 파일
| 파일 | 설명 |
|------|------|
| `app/Http/Requests/Quote/QuoteBomBulkCalculateRequest.php` | 다건 BOM 산출 FormRequest |
| `api/docs/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 |
| `api/docs/dev/changes/20260102_1300_quote_bom_bulk_calculation.md` | 변경 내용 문서 |
### 수정된 파일
| 파일 | 설명 |
@@ -93,9 +93,9 @@ ### QuoteCalculationService::calculateBomBulk()
- 개별 품목 실패가 전체에 영향 없음 (예외 처리)
## 관련 문서
- 계획 문서: `docs/plans/quote-calculation-api-plan.md`
- Phase 1.1 문서: `docs/changes/20260102_quote_bom_calculation_api.md`
- Phase 1.2 문서: `docs/changes/20260102_1300_quote_bom_bulk_calculation.md`
- 계획 문서: `docs/dev/dev_plans/quote-calculation-api-plan.md`
- Phase 1.1 문서: `docs/dev/changes/20260102_quote_bom_calculation_api.md`
- Phase 1.2 문서: `docs/dev/changes/20260102_1300_quote_bom_bulk_calculation.md`
## 다음 단계
- React 프론트엔드에서 `/calculate/bom/bulk` API 연동

View File

@@ -107,3 +107,10 @@ fixed_tools: []
# override of the corresponding setting in serena_config.yml, see the documentation there.
# If null or missing, the value from the global config is used.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:

View File

@@ -509,6 +509,7 @@ ### 2. Multi-tenancy & Models
- SoftDeletes by default
- Common columns: tenant_id, created_by, updated_by, deleted_by (COMMENT required)
- FK constraints: Created during design, minimal in production
- **🔴 쿼리 수정 시 모델 스코프 우선**: `where('컬럼', '값')` 하드코딩 전에 반드시 모델에 정의된 스코프(scopeActive 등)를 먼저 확인하고, 스코프가 있으면 `Model::active()` 형태로 사용할 것
### 3. Middleware Stack
- ApiKeyMiddleware, CheckSwaggerAuth, CorsMiddleware, CheckPermission, PermMapper

36
Jenkinsfile vendored
View File

@@ -17,7 +17,7 @@ pipeline {
script {
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
}
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
slackSend channel: '#deploy_api', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
@@ -37,7 +37,10 @@ pipeline {
ssh ${DEPLOY_USER}@211.117.60.189 '
cd /home/webservice/api-stage/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage bootstrap/cache &&
sudo chmod -R 775 storage bootstrap/cache &&
ln -sfn /home/webservice/api-stage/shared/.env .env &&
sudo chmod 640 /home/webservice/api-stage/shared/.env &&
ln -sfn /home/webservice/api-stage/shared/storage/app storage/app &&
composer install --no-dev --optimize-autoloader --no-interaction &&
php artisan config:cache &&
@@ -53,18 +56,18 @@ pipeline {
}
}
// ── 운영 배포 승인 ──
stage('Production Approval') {
when { branch 'main' }
steps {
slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token',
message: "🔔 *api* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>"
timeout(time: 24, unit: 'HOURS') {
input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr',
ok: '운영 배포 진행'
}
}
}
// ── 운영 배포 승인 (런칭 후 활성화) ──
// stage('Production Approval') {
// when { branch 'main' }
// steps {
// slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token',
// message: "🔔 *api* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>"
// timeout(time: 24, unit: 'HOURS') {
// input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr',
// ok: '운영 배포 진행'
// }
// }
// }
// ── main → 운영서버 Production 배포 ──
stage('Deploy Production') {
@@ -81,7 +84,10 @@ pipeline {
ssh ${DEPLOY_USER}@211.117.60.189 '
cd /home/webservice/api/releases/${RELEASE_ID} &&
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
sudo chown -R www-data:webservice storage bootstrap/cache &&
sudo chmod -R 775 storage bootstrap/cache &&
ln -sfn /home/webservice/api/shared/.env .env &&
sudo chmod 640 /home/webservice/api/shared/.env &&
ln -sfn /home/webservice/api/shared/storage/app storage/app &&
composer install --no-dev --optimize-autoloader --no-interaction &&
php artisan config:cache &&
@@ -103,11 +109,11 @@ pipeline {
post {
success {
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
slackSend channel: '#deploy_api', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
slackSend channel: '#deploy_api', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
script {
if (env.BRANCH_NAME == 'main') {

View File

@@ -1,6 +1,6 @@
# 논리적 데이터베이스 관계 문서
> **자동 생성**: 2026-02-21 16:28:35
> **자동 생성**: 2026-03-06 21:25:05
> **소스**: Eloquent 모델 관계 분석
## 📊 모델별 관계 현황
@@ -580,17 +580,10 @@ ### roles
**모델**: `App\Models\Permissions\Role`
- **tenant()**: belongsTo → `tenants`
- **menuPermissions()**: hasMany → `role_menu_permissions`
- **userRoles()**: hasMany → `user_roles`
- **users()**: belongsToMany → `users`
- **permissions()**: belongsToMany → `permissions`
### role_menu_permissions
**모델**: `App\Models\Permissions\RoleMenuPermission`
- **role()**: belongsTo → `roles`
- **menu()**: belongsTo → `menus`
### popups
**모델**: `App\Models\Popups\Popup`
@@ -637,6 +630,7 @@ ### work_orders
- **stepProgress()**: hasMany → `work_order_step_progress`
- **materialInputs()**: hasMany → `work_order_material_inputs`
- **shipments()**: hasMany → `shipments`
- **inspections()**: hasMany → `inspections`
- **bendingDetail()**: hasOne → `work_order_bending_details`
- **documents()**: morphMany → `documents`
@@ -743,6 +737,7 @@ ### push_notification_settings
### inspections
**모델**: `App\Models\Qualitys\Inspection`
- **workOrder()**: belongsTo → `work_orders`
- **item()**: belongsTo → `items`
- **inspector()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
@@ -758,6 +753,38 @@ ### lot_sales
- **lot()**: belongsTo → `lots`
### performance_reports
**모델**: `App\Models\Qualitys\PerformanceReport`
- **qualityDocument()**: belongsTo → `quality_documents`
- **confirmer()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
### quality_documents
**모델**: `App\Models\Qualitys\QualityDocument`
- **client()**: belongsTo → `clients`
- **inspector()**: belongsTo → `users`
- **creator()**: belongsTo → `users`
- **documentOrders()**: hasMany → `quality_document_orders`
- **locations()**: hasMany → `quality_document_locations`
- **performanceReport()**: hasOne → `performance_reports`
### quality_document_locations
**모델**: `App\Models\Qualitys\QualityDocumentLocation`
- **qualityDocument()**: belongsTo → `quality_documents`
- **qualityDocumentOrder()**: belongsTo → `quality_document_orders`
- **orderItem()**: belongsTo → `order_items`
- **document()**: belongsTo → `documents`
### quality_document_orders
**모델**: `App\Models\Qualitys\QualityDocumentOrder`
- **qualityDocument()**: belongsTo → `quality_documents`
- **order()**: belongsTo → `orders`
- **locations()**: hasMany → `quality_document_locations`
### quotes
**모델**: `App\Models\Quote\Quote`
@@ -836,6 +863,7 @@ ### approvals
- **steps()**: hasMany → `approval_steps`
- **approverSteps()**: hasMany → `approval_steps`
- **referenceSteps()**: hasMany → `approval_steps`
- **linkable()**: morphTo → `(Polymorphic)`
### approval_forms
**모델**: `App\Models\Tenants\ApprovalForm`
@@ -933,6 +961,16 @@ ### expense_accounts
- **vendor()**: belongsTo → `clients`
### journal_entrys
**모델**: `App\Models\Tenants\JournalEntry`
- **lines()**: hasMany → `journal_entry_lines`
### journal_entry_lines
**모델**: `App\Models\Tenants\JournalEntryLine`
- **journalEntry()**: belongsTo → `journal_entries`
### leaves
**모델**: `App\Models\Tenants\Leave`
@@ -961,7 +999,10 @@ ### leave_policys
### loans
**모델**: `App\Models\Tenants\Loan`
- **user()**: belongsTo → `users`
- **withdrawal()**: belongsTo → `withdrawals`
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
### payments
**모델**: `App\Models\Tenants\Payment`
@@ -1043,6 +1084,7 @@ ### shipments
- **creator()**: belongsTo → `users`
- **updater()**: belongsTo → `users`
- **items()**: hasMany → `shipment_items`
- **vehicleDispatches()**: hasMany → `shipment_vehicle_dispatches`
### shipment_items
**모델**: `App\Models\Tenants\ShipmentItem`
@@ -1050,6 +1092,11 @@ ### shipment_items
- **shipment()**: belongsTo → `shipments`
- **stockLot()**: belongsTo → `stock_lots`
### shipment_vehicle_dispatchs
**모델**: `App\Models\Tenants\ShipmentVehicleDispatch`
- **shipment()**: belongsTo → `shipments`
### sites
**모델**: `App\Models\Tenants\Site`
@@ -1147,6 +1194,8 @@ ### tenant_user_profiles
### today_issues
**모델**: `App\Models\Tenants\TodayIssue`
- **reader()**: belongsTo → `users`
- **targetUser()**: belongsTo → `users`
### withdrawals
**모델**: `App\Models\Tenants\Withdrawal`

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Models\Quote\Quote;
use Illuminate\Console\Command;
class BackfillQuoteProductCodeCommand extends Command
{
protected $signature = 'data:backfill-quote-product-code {--dry-run : 실제 저장하지 않고 결과만 출력}';
protected $description = 'quotes.product_code가 비어있는 레코드에 calculation_inputs.items[0].productCode 값 보정';
public function handle(): int
{
$dryRun = $this->option('dry-run');
$quotes = Quote::whereNull('product_code')
->whereNotNull('calculation_inputs')
->get();
$this->info("대상: {$quotes->count()}".($dryRun ? ' (dry-run)' : ''));
$updated = 0;
$skipped = 0;
foreach ($quotes as $quote) {
$inputs = $quote->calculation_inputs;
if (! is_array($inputs)) {
$inputs = json_decode($inputs, true);
}
$productCode = $inputs['items'][0]['productCode'] ?? null;
if (! $productCode) {
$skipped++;
$this->line(" SKIP #{$quote->id} ({$quote->quote_number}) — productCode 없음");
continue;
}
if (! $dryRun) {
$quote->update(['product_code' => $productCode]);
}
$updated++;
$this->line(' '.($dryRun ? 'WOULD ' : '')."UPDATE #{$quote->id} ({$quote->quote_number}) → {$productCode}");
}
$this->info("완료: 보정 {$updated}건, 스킵 {$skipped}");
return self::SUCCESS;
}
}

View File

@@ -29,7 +29,7 @@ class RecordStorageUsage extends Command
*/
public function handle(): int
{
$tenants = Tenant::where('status', 'active')->get();
$tenants = Tenant::active()->get();
$recorded = 0;
foreach ($tenants as $tenant) {

View File

@@ -17,25 +17,13 @@
class Handler extends ExceptionHandler
{
/**
* 특정 IP에서 발생하는 예외를 슬랙/로그에서 무시할지 확인
* 슬랙 알림에서 무시할 예외인지 확인
*/
protected function shouldIgnoreException(Throwable $e): bool
{
$ignoredIps = array_filter(
array_map('trim', explode(',', env('EXCEPTION_IGNORED_IPS', '')))
);
if (empty($ignoredIps)) {
return false;
}
$currentIp = request()?->ip();
// 무시할 IP 목록에 있고, '회원정보 정보 없음' 예외인 경우
if (in_array($currentIp, $ignoredIps, true)) {
if ($e instanceof AuthenticationException && $e->getMessage() === '회원정보 정보 없음') {
return true;
}
// 세션 만료로 인한 인증 실패는 슬랙 알림 제외 (API Key 검증 통과 후 발생하므로 정상 케이스)
if ($e instanceof AuthenticationException && $e->getMessage() === '회원정보 정보 없음') {
return true;
}
return false;

View File

@@ -7,6 +7,7 @@
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class DailyReportExport implements FromArray, ShouldAutoSize, WithHeadings, WithStyles, WithTitle
@@ -31,10 +32,10 @@ public function headings(): array
return [
['일일 일보 - '.$this->report['date']],
[],
['전일 잔액', number_format($this->report['previous_balance']).'원'],
['당 입금', number_format($this->report['daily_deposit']).'원'],
['당 출금', number_format($this->report['daily_withdrawal']).'원'],
['당일 잔액', number_format($this->report['current_balance']).'원'],
['전월 이월', number_format($this->report['previous_balance']).'원'],
['당 입금', number_format($this->report['daily_deposit']).'원'],
['당 출금', number_format($this->report['daily_withdrawal']).'원'],
['잔액', number_format($this->report['current_balance']).'원'],
[],
['구분', '거래처명', '계정과목', '입금액', '출금액', '적요'],
];
@@ -47,6 +48,7 @@ public function array(): array
{
$rows = [];
// ── 예금 입출금 내역 ──
foreach ($this->report['details'] as $detail) {
$rows[] = [
$detail['type_label'],
@@ -58,7 +60,7 @@ public function array(): array
];
}
// 합계 행 추가
// 합계 행
$rows[] = [];
$rows[] = [
'합계',
@@ -69,6 +71,37 @@ public function array(): array
'',
];
// ── 어음 및 외상매출채권 현황 ──
$noteReceivables = $this->report['note_receivables'] ?? [];
$rows[] = [];
$rows[] = [];
$rows[] = ['어음 및 외상매출채권 현황'];
$rows[] = ['No.', '내용', '금액', '발행일', '만기일'];
$noteTotal = 0;
$no = 1;
foreach ($noteReceivables as $item) {
$amount = $item['current_balance'] ?? 0;
$noteTotal += $amount;
$rows[] = [
$no++,
$item['content'] ?? '-',
$amount > 0 ? number_format($amount) : '',
$item['issue_date'] ?? '-',
$item['due_date'] ?? '-',
];
}
// 어음 합계
$rows[] = [
'합계',
'',
number_format($noteTotal),
'',
'',
];
return $rows;
}
@@ -77,7 +110,7 @@ public function array(): array
*/
public function styles(Worksheet $sheet): array
{
return [
$styles = [
1 => ['font' => ['bold' => true, 'size' => 14]],
3 => ['font' => ['bold' => true]],
4 => ['font' => ['bold' => true]],
@@ -86,10 +119,32 @@ public function styles(Worksheet $sheet): array
8 => [
'font' => ['bold' => true],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E0E0E0'],
],
],
];
// 어음 섹션 헤더 스타일 (동적 행 번호)
// headings 8행 + details 수 + 합계 2행 + 빈 2행 + 어음 제목 1행 + 어음 헤더 1행
$detailCount = count($this->report['details']);
$noteHeaderTitleRow = 8 + $detailCount + 2 + 2 + 1; // 어음 제목 행
$noteHeaderRow = $noteHeaderTitleRow + 1; // 어음 컬럼 헤더 행
$styles[$noteHeaderTitleRow] = ['font' => ['bold' => true, 'size' => 12]];
$styles[$noteHeaderRow] = [
'font' => ['bold' => true],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E0E0E0'],
],
];
// 어음 합계 행
$noteCount = count($this->report['note_receivables'] ?? []);
$noteTotalRow = $noteHeaderRow + $noteCount + 1;
$styles[$noteTotalRow] = ['font' => ['bold' => true]];
return $styles;
}
}

View File

@@ -14,7 +14,7 @@
* - 두께 매핑 (normalizeThickness)
* - 면적 계산 (calculateArea)
*
* @see docs/plans/5130-sam-data-migration-plan.md 섹션 4.5
* @see docs/dev_plans/5130-sam-data-migration-plan.md 섹션 4.5
*/
class Legacy5130Calculator
{

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\AccountSubject\StoreAccountSubjectRequest;
use App\Services\AccountCodeService;
use Illuminate\Http\Request;
class AccountSubjectController extends Controller
{
public function __construct(
private readonly AccountCodeService $service
) {}
/**
* 계정과목 목록 조회
*/
public function index(Request $request)
{
$params = $request->only(['search', 'category']);
$subjects = $this->service->index($params);
return ApiResponse::success($subjects, __('message.fetched'));
}
/**
* 계정과목 등록
*/
public function store(StoreAccountSubjectRequest $request)
{
$subject = $this->service->store($request->validated());
return ApiResponse::success($subject, __('message.created'), [], 201);
}
/**
* 계정과목 활성/비활성 토글
*/
public function toggleStatus(int $id, Request $request)
{
$isActive = (bool) $request->input('is_active', true);
$subject = $this->service->toggleStatus($id, $isActive);
return ApiResponse::success($subject, __('message.toggled'));
}
/**
* 계정과목 삭제
*/
public function destroy(int $id)
{
$this->service->destroy($id);
return ApiResponse::success(null, __('message.deleted'));
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\BarobillService;
use Illuminate\Http\Request;
class BarobillController extends Controller
{
public function __construct(
private BarobillService $barobillService
) {}
/**
* 연동 현황 조회
*/
public function status()
{
return ApiResponse::handle(function () {
$setting = $this->barobillService->getSetting();
return [
'bank_service_count' => 0,
'account_link_count' => 0,
'member' => $setting ? [
'barobill_id' => $setting->barobill_id,
'biz_no' => $setting->corp_num,
'status' => $setting->isVerified() ? 'active' : 'inactive',
'server_mode' => config('services.barobill.test_mode', true) ? 'test' : 'production',
] : null,
];
}, __('message.fetched'));
}
/**
* 바로빌 로그인 정보 등록
*/
public function login(Request $request)
{
$data = $request->validate([
'barobill_id' => 'required|string',
'password' => 'required|string',
]);
return ApiResponse::handle(function () use ($data) {
return $this->barobillService->saveSetting([
'barobill_id' => $data['barobill_id'],
]);
}, __('message.saved'));
}
/**
* 바로빌 회원가입 정보 등록
*/
public function signup(Request $request)
{
$data = $request->validate([
'business_number' => 'required|string|size:10',
'company_name' => 'required|string',
'ceo_name' => 'required|string',
'business_type' => 'nullable|string',
'business_category' => 'nullable|string',
'address' => 'nullable|string',
'barobill_id' => 'required|string',
'password' => 'required|string',
'manager_name' => 'nullable|string',
'manager_phone' => 'nullable|string',
'manager_email' => 'nullable|email',
]);
return ApiResponse::handle(function () use ($data) {
return $this->barobillService->saveSetting([
'corp_num' => $data['business_number'],
'corp_name' => $data['company_name'],
'ceo_name' => $data['ceo_name'],
'biz_type' => $data['business_type'] ?? null,
'biz_class' => $data['business_category'] ?? null,
'addr' => $data['address'] ?? null,
'barobill_id' => $data['barobill_id'],
'contact_name' => $data['manager_name'] ?? null,
'contact_tel' => $data['manager_phone'] ?? null,
'contact_id' => $data['manager_email'] ?? null,
]);
}, __('message.saved'));
}
/**
* 은행 빠른조회 서비스 URL 조회
*/
public function bankServiceUrl(Request $request)
{
return ApiResponse::handle(function () {
$baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Bank/BankAccountService'];
}, __('message.fetched'));
}
/**
* 계좌 연동 등록 URL 조회
*/
public function accountLinkUrl()
{
return ApiResponse::handle(function () {
$baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Bank/AccountLink'];
}, __('message.fetched'));
}
/**
* 카드 연동 등록 URL 조회
*/
public function cardLinkUrl()
{
return ApiResponse::handle(function () {
$baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Card/CardLink'];
}, __('message.fetched'));
}
/**
* 공인인증서 등록 URL 조회
*/
public function certificateUrl()
{
return ApiResponse::handle(function () {
$baseUrl = config('services.barobill.test_mode', true)
? 'https://testws.barobill.co.kr'
: 'https://ws.barobill.co.kr';
return ['url' => $baseUrl.'/Certificate/Register'];
}, __('message.fetched'));
}
}

View File

@@ -51,4 +51,56 @@ public function summary(Request $request)
);
}, __('message.fetched'));
}
/**
* 일정 등록
*/
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|string|max:200',
'description' => 'nullable|string|max:1000',
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i',
'is_all_day' => 'boolean',
'color' => 'nullable|string|max:20',
]);
return ApiResponse::handle(function () use ($validated) {
return $this->calendarService->createSchedule($validated);
}, __('message.created'));
}
/**
* 일정 수정
*/
public function update(Request $request, int $id)
{
$validated = $request->validate([
'title' => 'required|string|max:200',
'description' => 'nullable|string|max:1000',
'start_date' => 'required|date_format:Y-m-d',
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable|date_format:H:i',
'is_all_day' => 'boolean',
'color' => 'nullable|string|max:20',
]);
return ApiResponse::handle(function () use ($id, $validated) {
return $this->calendarService->updateSchedule($id, $validated);
}, __('message.updated'));
}
/**
* 일정 삭제
*/
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->calendarService->deleteSchedule($id);
}, __('message.deleted'));
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\CalendarScheduleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CalendarScheduleController extends Controller
{
public function __construct(
private readonly CalendarScheduleService $service
) {}
/**
* 일정 목록 조회
*/
public function index(Request $request): JsonResponse
{
$request->validate([
'year' => 'required|integer|min:2000|max:2100',
'type' => 'nullable|string',
]);
return ApiResponse::handle(
fn () => $this->service->list(
(int) $request->input('year'),
$request->input('type')
),
__('message.fetched')
);
}
/**
* 통계 조회
*/
public function stats(Request $request): JsonResponse
{
$request->validate([
'year' => 'required|integer|min:2000|max:2100',
]);
return ApiResponse::handle(
fn () => $this->service->stats((int) $request->input('year')),
__('message.fetched')
);
}
/**
* 단건 조회
*/
public function show(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->show($id),
__('message.fetched')
);
}
/**
* 등록
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event',
'is_recurring' => 'boolean',
'memo' => 'nullable|string|max:500',
]);
return ApiResponse::handle(
fn () => $this->service->store($validated),
__('message.created')
);
}
/**
* 수정
*/
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event',
'is_recurring' => 'boolean',
'memo' => 'nullable|string|max:500',
]);
return ApiResponse::handle(
fn () => $this->service->update($id, $validated),
__('message.updated')
);
}
/**
* 삭제
*/
public function destroy(int $id): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->delete($id),
__('message.deleted')
);
}
/**
* 대량 등록
*/
public function bulkStore(Request $request): JsonResponse
{
$validated = $request->validate([
'schedules' => 'required|array|min:1',
'schedules.*.name' => 'required|string|max:100',
'schedules.*.start_date' => 'required|date',
'schedules.*.end_date' => 'required|date|after_or_equal:schedules.*.start_date',
'schedules.*.type' => 'required|string|in:public_holiday,temporary_holiday,substitute_holiday,tax_deadline,company_event',
'schedules.*.is_recurring' => 'boolean',
'schedules.*.memo' => 'nullable|string|max:500',
]);
return ApiResponse::handle(
fn () => $this->service->bulkStore($validated['schedules']),
__('message.created')
);
}
}

View File

@@ -2,11 +2,14 @@
namespace App\Http\Controllers\Api\V1;
use App\Exports\DailyReportExport;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\DailyReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 일일 보고서 컨트롤러
@@ -58,4 +61,19 @@ public function summary(Request $request): JsonResponse
return $this->service->summary($params);
}, __('message.fetched'));
}
/**
* 일일 보고서 엑셀 다운로드
*/
public function export(Request $request): BinaryFileResponse
{
$params = $request->validate([
'date' => 'nullable|date',
]);
$reportData = $this->service->exportData($params);
$filename = '일일일보_'.$reportData['date'].'.xlsx';
return Excel::download(new DailyReportExport($reportData), $filename);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\DashboardCeoService;
use Illuminate\Http\JsonResponse;
/**
* CEO 대시보드 섹션별 API 컨트롤러
*
* 6개 섹션: 매출, 매입, 생산, 미출고, 시공, 근태
*/
class DashboardCeoController extends Controller
{
public function __construct(
private readonly DashboardCeoService $service
) {}
/**
* 매출 현황 요약
* GET /api/v1/dashboard/sales/summary
*/
public function salesSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->salesSummary(),
__('message.fetched')
);
}
/**
* 매입 현황 요약
* GET /api/v1/dashboard/purchases/summary
*/
public function purchasesSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->purchasesSummary(),
__('message.fetched')
);
}
/**
* 생산 현황 요약
* GET /api/v1/dashboard/production/summary
*/
public function productionSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->productionSummary(),
__('message.fetched')
);
}
/**
* 미출고 내역 요약
* GET /api/v1/dashboard/unshipped/summary
*/
public function unshippedSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->unshippedSummary(),
__('message.fetched')
);
}
/**
* 시공 현황 요약
* GET /api/v1/dashboard/construction/summary
*/
public function constructionSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->constructionSummary(),
__('message.fetched')
);
}
/**
* 근태 현황 요약
* GET /api/v1/dashboard/attendance/summary
*/
public function attendanceSummary(): JsonResponse
{
return ApiResponse::handle(
fn () => $this->service->attendanceSummary(),
__('message.fetched')
);
}
}

View File

@@ -74,6 +74,22 @@ public function destroy(int $id): JsonResponse
}, __('message.deleted'));
}
/**
* rendered_html 스냅샷 저장 (Lazy Snapshot)
* PATCH /v1/documents/{id}/snapshot
*/
public function patchSnapshot(int $id, UpdateRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
$renderedHtml = $request->validated()['rendered_html'] ?? null;
if (! $renderedHtml) {
throw new \Symfony\Component\HttpKernel\Exception\BadRequestHttpException('rendered_html is required');
}
return $this->service->patchSnapshot($id, $renderedHtml);
}, __('message.updated'));
}
// =========================================================================
// FQC 일괄생성 (제품검사)
// =========================================================================

View File

@@ -33,4 +33,20 @@ public function summary(Request $request): JsonResponse
return $this->entertainmentService->getSummary($limitType, $companyType, $year, $quarter);
}, __('message.fetched'));
}
/**
* 접대비 상세 조회 (모달용)
*/
public function detail(Request $request): JsonResponse
{
$companyType = $request->query('company_type', 'medium');
$year = $request->query('year') ? (int) $request->query('year') : null;
$quarter = $request->query('quarter') ? (int) $request->query('quarter') : null;
$startDate = $request->query('start_date');
$endDate = $request->query('end_date');
return ApiResponse::handle(function () use ($companyType, $year, $quarter, $startDate, $endDate) {
return $this->entertainmentService->getDetail($companyType, $year, $quarter, $startDate, $endDate);
}, __('message.fetched'));
}
}

View File

@@ -128,13 +128,16 @@ public function summary(Request $request)
/**
* 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용)
*
* @param Request $request transaction_type 쿼리 파라미터 (purchase, card, bill, null=전체)
* @param Request $request transaction_type (purchase, card, bill, null=전체), start_date, end_date, search
*/
public function dashboardDetail(Request $request)
{
$transactionType = $request->query('transaction_type');
$startDate = $request->query('start_date');
$endDate = $request->query('end_date');
$search = $request->query('search');
$data = $this->service->dashboardDetail($transactionType);
$data = $this->service->dashboardDetail($transactionType, $startDate, $endDate, $search);
return ApiResponse::success($data, __('message.fetched'));
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\GeneralJournalEntry\StoreManualJournalRequest;
use App\Http\Requests\V1\GeneralJournalEntry\UpdateJournalRequest;
use App\Services\GeneralJournalEntryService;
use Illuminate\Http\Request;
class GeneralJournalEntryController extends Controller
{
public function __construct(
private readonly GeneralJournalEntryService $service
) {}
/**
* 일반전표 통합 목록 조회
*/
public function index(Request $request)
{
$params = $request->only([
'start_date', 'end_date', 'search', 'page', 'per_page',
]);
$result = $this->service->index($params);
return ApiResponse::success($result, __('message.fetched'));
}
/**
* 요약 통계
*/
public function summary(Request $request)
{
$params = $request->only([
'start_date', 'end_date', 'search',
]);
$summary = $this->service->summary($params);
return ApiResponse::success($summary, __('message.fetched'));
}
/**
* 수기전표 등록
*/
public function store(StoreManualJournalRequest $request)
{
$entry = $this->service->store($request->validated());
return ApiResponse::success($entry, __('message.created'), [], 201);
}
/**
* 전표 상세 조회 (분개 수정 모달용)
*/
public function show(int $id)
{
$detail = $this->service->show($id);
return ApiResponse::success($detail, __('message.fetched'));
}
/**
* 분개 수정
*/
public function updateJournal(int $id, UpdateJournalRequest $request)
{
$entry = $this->service->updateJournal($id, $request->validated());
return ApiResponse::success($entry, __('message.updated'));
}
/**
* 분개 삭제
*/
public function destroyJournal(int $id)
{
$this->service->destroyJournal($id);
return ApiResponse::success(null, __('message.deleted'));
}
}

View File

@@ -34,6 +34,16 @@ public function stats(Request $request)
}, __('message.inspection.fetched'));
}
/**
* 캘린더 스케줄 조회
*/
public function calendar(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->calendar($request->all());
}, __('message.inspection.fetched'));
}
/**
* 단건 조회
*/

View File

@@ -11,6 +11,7 @@
use App\Http\Requests\Loan\LoanUpdateRequest;
use App\Services\LoanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LoanController extends Controller
{
@@ -33,8 +34,10 @@ public function index(LoanIndexRequest $request): JsonResponse
*/
public function summary(LoanIndexRequest $request): JsonResponse
{
$userId = $request->validated()['user_id'] ?? null;
$result = $this->loanService->summary($userId);
$validated = $request->validated();
$userId = $validated['user_id'] ?? null;
$category = $validated['category'] ?? null;
$result = $this->loanService->summary($userId, $category);
return ApiResponse::success($result, __('message.fetched'));
}
@@ -42,9 +45,12 @@ public function summary(LoanIndexRequest $request): JsonResponse
/**
* 가지급금 대시보드
*/
public function dashboard(): JsonResponse
public function dashboard(Request $request): JsonResponse
{
$result = $this->loanService->dashboard();
$startDate = $request->query('start_date');
$endDate = $request->query('end_date');
$result = $this->loanService->dashboard($startDate, $endDate);
return ApiResponse::success($result, __('message.fetched'));
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quality\PerformanceReportConfirmRequest;
use App\Http\Requests\Quality\PerformanceReportMemoRequest;
use App\Services\PerformanceReportService;
use Illuminate\Http\Request;
class PerformanceReportController extends Controller
{
public function __construct(private PerformanceReportService $service) {}
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
public function stats(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->all());
}, __('message.fetched'));
}
public function confirm(PerformanceReportConfirmRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->confirm($request->validated()['ids']);
}, __('message.updated'));
}
public function unconfirm(PerformanceReportConfirmRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->unconfirm($request->validated()['ids']);
}, __('message.updated'));
}
public function updateMemo(PerformanceReportMemoRequest $request)
{
return ApiResponse::handle(function () use ($request) {
$data = $request->validated();
return $this->service->updateMemo($data['ids'], $data['memo']);
}, __('message.updated'));
}
public function missing(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->missing($request->all());
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProductionOrder\ProductionOrderIndexRequest;
use App\Services\ProductionOrderService;
use Illuminate\Http\JsonResponse;
class ProductionOrderController extends Controller
{
public function __construct(
private readonly ProductionOrderService $service
) {}
/**
* 생산지시 목록 조회
*/
public function index(ProductionOrderIndexRequest $request): JsonResponse
{
$result = $this->service->index($request->validated());
return ApiResponse::success($result, __('message.fetched'));
}
/**
* 생산지시 상태별 통계
*/
public function stats(): JsonResponse
{
$stats = $this->service->stats();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 생산지시 상세 조회
*/
public function show(int $orderId): JsonResponse
{
try {
$detail = $this->service->show($orderId);
return ApiResponse::success($detail, __('message.fetched'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.order.not_found'), 404);
}
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\Quality\QualityDocumentStoreRequest;
use App\Http\Requests\Quality\QualityDocumentUpdateRequest;
use App\Services\QualityDocumentService;
use Illuminate\Http\Request;
class QualityDocumentController extends Controller
{
public function __construct(private QualityDocumentService $service) {}
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->index($request->all());
}, __('message.fetched'));
}
public function stats(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->stats($request->all());
}, __('message.fetched'));
}
public function calendar(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->calendar($request->all());
}, __('message.fetched'));
}
public function availableOrders(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->availableOrders($request->all());
}, __('message.fetched'));
}
public function show(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
public function store(QualityDocumentStoreRequest $request)
{
return ApiResponse::handle(function () use ($request) {
return $this->service->store($request->validated());
}, __('message.created'));
}
public function update(QualityDocumentUpdateRequest $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->update($id, $request->validated());
}, __('message.updated'));
}
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->destroy($id);
return 'success';
}, __('message.deleted'));
}
public function complete(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->complete($id);
}, __('message.updated'));
}
public function attachOrders(Request $request, int $id)
{
$request->validate([
'order_ids' => ['required', 'array', 'min:1'],
'order_ids.*' => ['required', 'integer'],
]);
return ApiResponse::handle(function () use ($request, $id) {
return $this->service->attachOrders($id, $request->input('order_ids'));
}, __('message.updated'));
}
public function detachOrder(int $id, int $orderId)
{
return ApiResponse::handle(function () use ($id, $orderId) {
return $this->service->detachOrder($id, $orderId);
}, __('message.updated'));
}
public function inspectLocation(Request $request, int $id, int $locId)
{
$request->validate([
'post_width' => ['nullable', 'integer'],
'post_height' => ['nullable', 'integer'],
'change_reason' => ['nullable', 'string', 'max:500'],
'inspection_status' => ['nullable', 'string', 'in:pending,completed'],
]);
return ApiResponse::handle(function () use ($request, $id, $locId) {
return $this->service->inspectLocation($id, $locId, $request->all());
}, __('message.updated'));
}
public function requestDocument(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->requestDocument($id);
}, __('message.fetched'));
}
public function resultDocument(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->resultDocument($id);
}, __('message.fetched'));
}
}

View File

@@ -29,6 +29,8 @@ public function index(Request $request): JsonResponse
'sort_dir',
'per_page',
'page',
'start_date',
'end_date',
]);
$stocks = $this->service->index($params);

View File

@@ -20,9 +20,10 @@ public function __construct(
public function summary(Request $request): JsonResponse
{
$limit = (int) $request->input('limit', 30);
$date = $request->input('date'); // YYYY-MM-DD (이전 이슈 조회용)
return ApiResponse::handle(function () use ($limit) {
return $this->todayIssueService->summary($limit);
return ApiResponse::handle(function () use ($limit, $date) {
return $this->todayIssueService->summary($limit, null, $date);
}, __('message.fetched'));
}

View File

@@ -32,4 +32,18 @@ public function summary(Request $request): JsonResponse
return $this->vatService->getSummary($periodType, $year, $period);
}, __('message.fetched'));
}
/**
* 부가세 상세 조회 (모달용)
*/
public function detail(Request $request): JsonResponse
{
$periodType = $request->query('period_type', 'quarter');
$year = $request->query('year') ? (int) $request->query('year') : null;
$period = $request->query('period') ? (int) $request->query('period') : null;
return ApiResponse::handle(function () use ($periodType, $year, $period) {
return $this->vatService->getDetail($periodType, $year, $period);
}, __('message.fetched'));
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\VehicleDispatch\VehicleDispatchUpdateRequest;
use App\Services\VehicleDispatchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VehicleDispatchController extends Controller
{
public function __construct(
private readonly VehicleDispatchService $service
) {}
/**
* 배차차량 목록 조회
*/
public function index(Request $request): JsonResponse
{
$params = $request->only([
'search',
'status',
'start_date',
'end_date',
'per_page',
'page',
]);
$dispatches = $this->service->index($params);
return ApiResponse::success($dispatches, __('message.fetched'));
}
/**
* 배차차량 통계 조회
*/
public function stats(): JsonResponse
{
$stats = $this->service->stats();
return ApiResponse::success($stats, __('message.fetched'));
}
/**
* 배차차량 상세 조회
*/
public function show(int $id): JsonResponse
{
try {
$dispatch = $this->service->show($id);
return ApiResponse::success($dispatch, __('message.fetched'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.not_found'), 404);
}
}
/**
* 배차차량 수정
*/
public function update(VehicleDispatchUpdateRequest $request, int $id): JsonResponse
{
try {
$dispatch = $this->service->update($id, $request->validated());
return ApiResponse::success($dispatch, __('message.updated'));
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
return ApiResponse::error(__('error.not_found'), 404);
}
}
}

View File

@@ -230,6 +230,16 @@ public function inspectionReport(int $id)
}, __('message.work_order.fetched'));
}
/**
* 작업지시 검사 설정 조회 (공정 자동 판별 + 구성품 목록)
*/
public function inspectionConfig(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->getInspectionConfig($id);
}, __('message.work_order.fetched'));
}
/**
* 작업지시의 검사용 문서 템플릿 조회
*/
@@ -310,7 +320,14 @@ public function materialsForItem(int $id, int $itemId)
public function registerMaterialInputForItem(MaterialInputForItemRequest $request, int $id, int $itemId)
{
return ApiResponse::handle(function () use ($request, $id, $itemId) {
return $this->service->registerMaterialInputForItem($id, $itemId, $request->validated()['inputs']);
$validated = $request->validated();
return $this->service->registerMaterialInputForItem(
$id,
$itemId,
$validated['inputs'],
(bool) ($validated['replace'] ?? false)
);
}, __('message.work_order.material_input_registered'));
}

View File

@@ -22,6 +22,8 @@ public function rules(): array
'sort_dir' => 'nullable|string|in:asc,desc',
'per_page' => 'nullable|integer|min:1',
'page' => 'nullable|integer|min:1',
'start_date' => 'nullable|date_format:Y-m-d',
'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date',
];
}
}

View File

@@ -65,7 +65,7 @@ public function rules(): array
'mobile' => 'nullable|string|max:20',
'fax' => 'nullable|string|max:20',
'email' => 'nullable|email|max:100',
'address' => 'nullable|string|max:255',
'address' => 'nullable|string|max:500',
// 담당자 정보
'manager_name' => 'nullable|string|max:50',
'manager_tel' => 'nullable|string|max:20',

View File

@@ -65,7 +65,7 @@ public function rules(): array
'mobile' => 'nullable|string|max:20',
'fax' => 'nullable|string|max:20',
'email' => 'nullable|email|max:100',
'address' => 'nullable|string|max:255',
'address' => 'nullable|string|max:500',
// 담당자 정보
'manager_name' => 'nullable|string|max:50',
'manager_tel' => 'nullable|string|max:20',

View File

@@ -28,6 +28,9 @@ public function rules(): array
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
'approvers.*.role' => 'nullable|string|max:50',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// 문서 데이터 (EAV)
'data' => 'nullable|array',
'data.*.section_id' => 'nullable|integer',

View File

@@ -27,6 +27,9 @@ public function rules(): array
'approvers.*.user_id' => 'required_with:approvers|integer|exists:users,id',
'approvers.*.role' => 'nullable|string|max:50',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// 문서 데이터 (EAV)
'data' => 'nullable|array',
'data.*.section_id' => 'nullable|integer',

View File

@@ -30,6 +30,9 @@ public function rules(): array
'data.*.field_key' => 'required_with:data|string|max:100',
'data.*.field_value' => 'nullable|string',
// HTML 스냅샷
'rendered_html' => 'nullable|string',
// 첨부파일
'attachments' => 'nullable|array',
'attachments.*.file_id' => 'required_with:attachments|integer|exists:files,id',

View File

@@ -22,6 +22,7 @@ public function rules(): array
Inspection::TYPE_FQC,
])],
'lot_no' => ['required', 'string', 'max:50'],
'work_order_id' => ['nullable', 'integer', 'exists:work_orders,id'],
'item_name' => ['nullable', 'string', 'max:200'],
'process_name' => ['nullable', 'string', 'max:100'],
'quantity' => ['nullable', 'numeric', 'min:0'],

View File

@@ -29,6 +29,7 @@ public function rules(): array
return [
'user_id' => ['nullable', 'integer', 'exists:users,id'],
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)],
'start_date' => ['nullable', 'date', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
'search' => ['nullable', 'string', 'max:100'],

View File

@@ -2,7 +2,9 @@
namespace App\Http\Requests\Loan;
use App\Models\Tenants\Loan;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class LoanStoreRequest extends FormRequest
{
@@ -21,12 +23,27 @@ public function authorize(): bool
*/
public function rules(): array
{
$isGiftCertificate = $this->input('category') === Loan::CATEGORY_GIFT_CERTIFICATE;
return [
'user_id' => ['required', 'integer', 'exists:users,id'],
'user_id' => [$isGiftCertificate ? 'nullable' : 'required', 'integer', 'exists:users,id'],
'loan_date' => ['required', 'date', 'date_format:Y-m-d'],
'amount' => ['required', 'numeric', 'min:0', 'max:999999999999.99'],
'purpose' => ['nullable', 'string', 'max:1000'],
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
'category' => ['nullable', 'string', Rule::in(Loan::CATEGORIES)],
'status' => ['nullable', 'string', Rule::in(Loan::STATUSES)],
'metadata' => ['nullable', 'array'],
'metadata.serial_number' => ['nullable', 'string', 'max:100'],
'metadata.cert_name' => ['nullable', 'string', 'max:200'],
'metadata.vendor_id' => ['nullable', 'string', 'max:50'],
'metadata.vendor_name' => ['nullable', 'string', 'max:200'],
'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'],
'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'],
'metadata.recipient_name' => ['nullable', 'string', 'max:100'],
'metadata.recipient_organization' => ['nullable', 'string', 'max:200'],
'metadata.usage_description' => ['nullable', 'string', 'max:1000'],
'metadata.memo' => ['nullable', 'string', 'max:2000'],
];
}

View File

@@ -2,7 +2,9 @@
namespace App\Http\Requests\Loan;
use App\Models\Tenants\Loan;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class LoanUpdateRequest extends FormRequest
{
@@ -27,6 +29,20 @@ public function rules(): array
'amount' => ['sometimes', 'numeric', 'min:0', 'max:999999999999.99'],
'purpose' => ['nullable', 'string', 'max:1000'],
'withdrawal_id' => ['nullable', 'integer', 'exists:withdrawals,id'],
'category' => ['sometimes', 'string', Rule::in(Loan::CATEGORIES)],
'status' => ['sometimes', 'string', Rule::in(Loan::STATUSES)],
'settlement_date' => ['nullable', 'date', 'date_format:Y-m-d'],
'metadata' => ['nullable', 'array'],
'metadata.serial_number' => ['nullable', 'string', 'max:100'],
'metadata.cert_name' => ['nullable', 'string', 'max:200'],
'metadata.vendor_id' => ['nullable', 'string', 'max:50'],
'metadata.vendor_name' => ['nullable', 'string', 'max:200'],
'metadata.purchase_purpose' => ['nullable', 'string', 'max:50'],
'metadata.entertainment_expense' => ['nullable', 'string', 'max:50'],
'metadata.recipient_name' => ['nullable', 'string', 'max:100'],
'metadata.recipient_organization' => ['nullable', 'string', 'max:200'],
'metadata.usage_description' => ['nullable', 'string', 'max:1000'],
'metadata.memo' => ['nullable', 'string', 'max:2000'],
];
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\ProductionOrder;
use Illuminate\Foundation\Http\FormRequest;
class ProductionOrderIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'search' => 'nullable|string|max:100',
'production_status' => 'nullable|in:waiting,in_production,completed',
'sort_by' => 'nullable|in:created_at,delivery_date,order_no',
'sort_dir' => 'nullable|in:asc,desc',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:100',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class PerformanceReportConfirmRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['required', 'integer', 'exists:performance_reports,id'],
];
}
public function messages(): array
{
return [
'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']),
'ids.min' => __('validation.min.array', ['attribute' => '실적신고 ID', 'min' => 1]),
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class PerformanceReportMemoRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['required', 'integer', 'exists:performance_reports,id'],
'memo' => ['required', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'ids.required' => __('validation.required', ['attribute' => '실적신고 ID']),
'memo.required' => __('validation.required', ['attribute' => '메모']),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class QualityDocumentStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'site_name' => ['required', 'string', 'max:200'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'inspector_id' => ['nullable', 'integer', 'exists:users,id'],
'received_date' => ['nullable', 'date'],
'options' => ['nullable', 'array'],
'options.manager' => ['nullable', 'array'],
'options.manager.name' => ['nullable', 'string', 'max:50'],
'options.manager.phone' => ['nullable', 'string', 'max:30'],
'options.inspection' => ['nullable', 'array'],
'options.inspection.request_date' => ['nullable', 'date'],
'options.inspection.start_date' => ['nullable', 'date'],
'options.inspection.end_date' => ['nullable', 'date'],
'options.site_address' => ['nullable', 'array'],
'options.construction_site' => ['nullable', 'array'],
'options.material_distributor' => ['nullable', 'array'],
'options.contractor' => ['nullable', 'array'],
'options.supervisor' => ['nullable', 'array'],
'order_ids' => ['nullable', 'array'],
'order_ids.*' => ['integer', 'exists:orders,id'],
];
}
public function messages(): array
{
return [
'site_name.required' => __('validation.required', ['attribute' => '현장명']),
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\Quality;
use Illuminate\Foundation\Http\FormRequest;
class QualityDocumentUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'site_name' => ['sometimes', 'string', 'max:200'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
'inspector_id' => ['nullable', 'integer', 'exists:users,id'],
'received_date' => ['nullable', 'date'],
'options' => ['nullable', 'array'],
'options.manager' => ['nullable', 'array'],
'options.manager.name' => ['nullable', 'string', 'max:50'],
'options.manager.phone' => ['nullable', 'string', 'max:30'],
'options.inspection' => ['nullable', 'array'],
'options.inspection.request_date' => ['nullable', 'date'],
'options.inspection.start_date' => ['nullable', 'date'],
'options.inspection.end_date' => ['nullable', 'date'],
'options.site_address' => ['nullable', 'array'],
'options.construction_site' => ['nullable', 'array'],
'options.material_distributor' => ['nullable', 'array'],
'options.contractor' => ['nullable', 'array'],
'options.supervisor' => ['nullable', 'array'],
'order_ids' => ['nullable', 'array'],
'order_ids.*' => ['integer', 'exists:orders,id'],
'locations' => ['nullable', 'array'],
'locations.*.id' => ['required', 'integer'],
'locations.*.post_width' => ['nullable', 'integer'],
'locations.*.post_height' => ['nullable', 'integer'],
'locations.*.change_reason' => ['nullable', 'string', 'max:500'],
'locations.*.inspection_data' => ['nullable', 'array'],
];
}
}

View File

@@ -21,7 +21,7 @@ public function rules(): array
'scheduled_date' => 'required|date',
'status' => 'nullable|in:scheduled,ready,shipping,completed',
'priority' => 'nullable|in:urgent,normal,low',
'delivery_method' => 'nullable|in:pickup,direct,logistics',
'delivery_method' => 'nullable|in:pickup,direct,logistics,direct_dispatch,loading,kyungdong_delivery,daesin_delivery,kyungdong_freight,daesin_freight,self_pickup',
// 발주처/배송 정보
'client_id' => 'nullable|integer|exists:clients,id',
@@ -55,6 +55,16 @@ public function rules(): array
// 기타
'remarks' => 'nullable|string',
// 배차정보
'vehicle_dispatches' => 'nullable|array',
'vehicle_dispatches.*.seq' => 'nullable|integer|min:1',
'vehicle_dispatches.*.logistics_company' => 'nullable|string|max:100',
'vehicle_dispatches.*.arrival_datetime' => 'nullable|date',
'vehicle_dispatches.*.tonnage' => 'nullable|string|max:20',
'vehicle_dispatches.*.vehicle_no' => 'nullable|string|max:20',
'vehicle_dispatches.*.driver_contact' => 'nullable|string|max:50',
'vehicle_dispatches.*.remarks' => 'nullable|string',
// 출하 품목
'items' => 'nullable|array',
'items.*.seq' => 'nullable|integer|min:1',

View File

@@ -19,7 +19,7 @@ public function rules(): array
'order_id' => 'nullable|integer|exists:orders,id',
'scheduled_date' => 'nullable|date',
'priority' => 'nullable|in:urgent,normal,low',
'delivery_method' => 'nullable|in:pickup,direct,logistics',
'delivery_method' => 'nullable|in:pickup,direct,logistics,direct_dispatch,loading,kyungdong_delivery,daesin_delivery,kyungdong_freight,daesin_freight,self_pickup',
// 발주처/배송 정보
'client_id' => 'nullable|integer|exists:clients,id',
@@ -53,6 +53,16 @@ public function rules(): array
// 기타
'remarks' => 'nullable|string',
// 배차정보
'vehicle_dispatches' => 'nullable|array',
'vehicle_dispatches.*.seq' => 'nullable|integer|min:1',
'vehicle_dispatches.*.logistics_company' => 'nullable|string|max:100',
'vehicle_dispatches.*.arrival_datetime' => 'nullable|date',
'vehicle_dispatches.*.tonnage' => 'nullable|string|max:20',
'vehicle_dispatches.*.vehicle_no' => 'nullable|string|max:20',
'vehicle_dispatches.*.driver_contact' => 'nullable|string|max:50',
'vehicle_dispatches.*.remarks' => 'nullable|string',
// 출하 품목
'items' => 'nullable|array',
'items.*.seq' => 'nullable|integer|min:1',

View File

@@ -29,7 +29,7 @@ public function rules(): array
'briefing_time' => 'nullable|string|max:10',
'briefing_type' => ['nullable', 'string', Rule::in(SiteBriefing::TYPES)],
'location' => 'nullable|string|max:200',
'address' => 'nullable|string|max:255',
'address' => 'nullable|string|max:500',
// 상태 정보
'status' => ['nullable', 'string', Rule::in(SiteBriefing::STATUSES)],

View File

@@ -29,7 +29,7 @@ public function rules(): array
'briefing_time' => 'nullable|string|max:10',
'briefing_type' => ['nullable', 'string', Rule::in(SiteBriefing::TYPES)],
'location' => 'nullable|string|max:200',
'address' => 'nullable|string|max:255',
'address' => 'nullable|string|max:500',
// 상태 정보
'status' => ['nullable', 'string', Rule::in(SiteBriefing::STATUSES)],

View File

@@ -17,7 +17,7 @@ public function rules(): array
'company_name' => 'required|string|max:100',
'email' => 'nullable|email|max:100',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:255',
'address' => 'nullable|string|max:500',
'business_num' => 'nullable|string|max:20',
'ceo_name' => 'nullable|string|max:100',
];

View File

@@ -18,7 +18,7 @@ public function rules(): array
'company_name' => 'sometimes|string|max:100',
'email' => 'nullable|email|max:100',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:255',
'address' => 'nullable|string|max:500',
'business_num' => 'nullable|string|max:20',
'ceo_name' => 'nullable|string|max:100',
'logo' => 'nullable|string|max:255',

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\V1\AccountSubject;
use Illuminate\Foundation\Http\FormRequest;
class StoreAccountSubjectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => ['required', 'string', 'max:10'],
'name' => ['required', 'string', 'max:100'],
'category' => ['nullable', 'string', 'in:asset,liability,capital,revenue,expense'],
];
}
public function messages(): array
{
return [
'code.required' => '계정과목 코드를 입력하세요.',
'name.required' => '계정과목명을 입력하세요.',
'category.in' => '유효한 분류를 선택하세요.',
];
}
}

View File

@@ -17,6 +17,7 @@ public function rules(): array
$tenantId = app('tenant_id') ?? 0;
return [
// === 기존 필드 ===
'bill_number' => [
'nullable',
'string',
@@ -30,16 +31,99 @@ public function rules(): array
'client_name' => ['nullable', 'string', 'max:100'],
'amount' => ['required', 'numeric', 'min:0'],
'issue_date' => ['required', 'date'],
'maturity_date' => ['required', 'date', 'after_or_equal:issue_date'],
'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'],
'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'],
'status' => ['nullable', 'string', 'max:30'],
'reason' => ['nullable', 'string', 'max:255'],
'installment_count' => ['nullable', 'integer', 'min:0'],
'note' => ['nullable', 'string', 'max:1000'],
'is_electronic' => ['nullable', 'boolean'],
'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'],
// === V8 증권종류/매체/구분 ===
'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'],
'medium' => ['nullable', 'string', 'in:electronic,paper'],
'bill_category' => ['nullable', 'string', 'in:commercial,other'],
// === 전자어음 ===
'electronic_bill_no' => ['nullable', 'string', 'max:100'],
'registration_org' => ['nullable', 'string', 'in:kftc,bank'],
// === 환어음 ===
'drawee' => ['nullable', 'string', 'max:100'],
'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'],
'acceptance_date' => ['nullable', 'date'],
'acceptance_refusal_date' => ['nullable', 'date'],
'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'],
// === 받을어음 전용 ===
'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'],
'endorsement_order' => ['nullable', 'string', 'max:5'],
'storage_place' => ['nullable', 'string', 'in:safe,bank,other'],
'issuer_bank' => ['nullable', 'string', 'max:100'],
// 할인
'is_discounted' => ['nullable', 'boolean'],
'discount_date' => ['nullable', 'date'],
'discount_bank' => ['nullable', 'string', 'max:100'],
'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
'discount_amount' => ['nullable', 'numeric', 'min:0'],
// 배서양도
'endorsement_date' => ['nullable', 'date'],
'endorsee' => ['nullable', 'string', 'max:100'],
'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'],
// 추심
'collection_bank' => ['nullable', 'string', 'max:100'],
'collection_request_date' => ['nullable', 'date'],
'collection_fee' => ['nullable', 'numeric', 'min:0'],
'collection_complete_date' => ['nullable', 'date'],
'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'],
'collection_deposit_date' => ['nullable', 'date'],
'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'],
// === 지급어음 전용 ===
'settlement_bank' => ['nullable', 'string', 'max:100'],
'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'],
'actual_payment_date' => ['nullable', 'date'],
// === 공통 ===
'payment_place' => ['nullable', 'string', 'max:30'],
'payment_place_detail' => ['nullable', 'string', 'max:200'],
// 개서
'renewal_date' => ['nullable', 'date'],
'renewal_new_bill_no' => ['nullable', 'string', 'max:50'],
'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'],
// 소구
'recourse_date' => ['nullable', 'date'],
'recourse_amount' => ['nullable', 'numeric', 'min:0'],
'recourse_target' => ['nullable', 'string', 'max:100'],
'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'],
// 환매
'buyback_date' => ['nullable', 'date'],
'buyback_amount' => ['nullable', 'numeric', 'min:0'],
'buyback_bank' => ['nullable', 'string', 'max:100'],
// 부도/법적절차
'dishonored_date' => ['nullable', 'date'],
'dishonored_reason' => ['nullable', 'string', 'max:30'],
'has_protest' => ['nullable', 'boolean'],
'protest_date' => ['nullable', 'date'],
'recourse_notice_date' => ['nullable', 'date'],
'recourse_notice_deadline' => ['nullable', 'date'],
// 분할배서
'is_split' => ['nullable', 'boolean'],
// === 차수 관리 ===
'installments' => ['nullable', 'array'],
'installments.*.date' => ['required_with:installments', 'date'],
'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'],
'installments.*.type' => ['nullable', 'string', 'max:30'],
'installments.*.counterparty' => ['nullable', 'string', 'max:100'],
'installments.*.note' => ['nullable', 'string', 'max:255'],
];
}

View File

@@ -14,6 +14,7 @@ public function authorize(): bool
public function rules(): array
{
return [
// === 기존 필드 ===
'bill_number' => ['nullable', 'string', 'max:50'],
'bill_type' => ['nullable', 'string', 'in:received,issued'],
'client_id' => ['nullable', 'integer', 'exists:clients,id'],
@@ -21,15 +22,72 @@ public function rules(): array
'amount' => ['nullable', 'numeric', 'min:0'],
'issue_date' => ['nullable', 'date'],
'maturity_date' => ['nullable', 'date', 'after_or_equal:issue_date'],
'status' => ['nullable', 'string', 'in:stored,maturityAlert,maturityResult,paymentComplete,dishonored,collectionRequest,collectionComplete,suing'],
'status' => ['nullable', 'string', 'max:30'],
'reason' => ['nullable', 'string', 'max:255'],
'installment_count' => ['nullable', 'integer', 'min:0'],
'note' => ['nullable', 'string', 'max:1000'],
'is_electronic' => ['nullable', 'boolean'],
'bank_account_id' => ['nullable', 'integer', 'exists:bank_accounts,id'],
// === V8 확장 ===
'instrument_type' => ['nullable', 'string', 'in:promissory,exchange,cashierCheck,currentCheck'],
'medium' => ['nullable', 'string', 'in:electronic,paper'],
'bill_category' => ['nullable', 'string', 'in:commercial,other'],
'electronic_bill_no' => ['nullable', 'string', 'max:100'],
'registration_org' => ['nullable', 'string', 'in:kftc,bank'],
'drawee' => ['nullable', 'string', 'max:100'],
'acceptance_status' => ['nullable', 'string', 'in:accepted,pending,refused'],
'acceptance_date' => ['nullable', 'date'],
'acceptance_refusal_date' => ['nullable', 'date'],
'acceptance_refusal_reason' => ['nullable', 'string', 'max:50'],
'endorsement' => ['nullable', 'string', 'in:endorsable,nonEndorsable'],
'endorsement_order' => ['nullable', 'string', 'max:5'],
'storage_place' => ['nullable', 'string', 'in:safe,bank,other'],
'issuer_bank' => ['nullable', 'string', 'max:100'],
'is_discounted' => ['nullable', 'boolean'],
'discount_date' => ['nullable', 'date'],
'discount_bank' => ['nullable', 'string', 'max:100'],
'discount_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
'discount_amount' => ['nullable', 'numeric', 'min:0'],
'endorsement_date' => ['nullable', 'date'],
'endorsee' => ['nullable', 'string', 'max:100'],
'endorsement_reason' => ['nullable', 'string', 'in:payment,guarantee,collection,other'],
'collection_bank' => ['nullable', 'string', 'max:100'],
'collection_request_date' => ['nullable', 'date'],
'collection_fee' => ['nullable', 'numeric', 'min:0'],
'collection_complete_date' => ['nullable', 'date'],
'collection_result' => ['nullable', 'string', 'in:success,partial,failed,pending'],
'collection_deposit_date' => ['nullable', 'date'],
'collection_deposit_amount' => ['nullable', 'numeric', 'min:0'],
'settlement_bank' => ['nullable', 'string', 'max:100'],
'payment_method' => ['nullable', 'string', 'in:autoTransfer,currentAccount,other'],
'actual_payment_date' => ['nullable', 'date'],
'payment_place' => ['nullable', 'string', 'max:30'],
'payment_place_detail' => ['nullable', 'string', 'max:200'],
'renewal_date' => ['nullable', 'date'],
'renewal_new_bill_no' => ['nullable', 'string', 'max:50'],
'renewal_reason' => ['nullable', 'string', 'in:maturityExtension,amountChange,conditionChange,other'],
'recourse_date' => ['nullable', 'date'],
'recourse_amount' => ['nullable', 'numeric', 'min:0'],
'recourse_target' => ['nullable', 'string', 'max:100'],
'recourse_reason' => ['nullable', 'string', 'in:endorsedDishonor,discountDishonor,other'],
'buyback_date' => ['nullable', 'date'],
'buyback_amount' => ['nullable', 'numeric', 'min:0'],
'buyback_bank' => ['nullable', 'string', 'max:100'],
'dishonored_date' => ['nullable', 'date'],
'dishonored_reason' => ['nullable', 'string', 'max:30'],
'has_protest' => ['nullable', 'boolean'],
'protest_date' => ['nullable', 'date'],
'recourse_notice_date' => ['nullable', 'date'],
'recourse_notice_deadline' => ['nullable', 'date'],
'is_split' => ['nullable', 'boolean'],
// === 차수 관리 ===
'installments' => ['nullable', 'array'],
'installments.*.date' => ['required_with:installments', 'date'],
'installments.*.amount' => ['required_with:installments', 'numeric', 'min:0'],
'installments.*.type' => ['nullable', 'string', 'max:30'],
'installments.*.counterparty' => ['nullable', 'string', 'max:100'],
'installments.*.note' => ['nullable', 'string', 'max:255'],
];
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests\V1\GeneralJournalEntry;
use Illuminate\Foundation\Http\FormRequest;
class StoreManualJournalRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'journal_date' => ['required', 'date'],
'description' => ['nullable', 'string', 'max:500'],
'rows' => ['required', 'array', 'min:2'],
'rows.*.side' => ['required', 'in:debit,credit'],
'rows.*.account_subject_id' => ['required', 'string', 'max:10'],
'rows.*.vendor_id' => ['nullable', 'integer'],
'rows.*.debit_amount' => ['required', 'integer', 'min:0'],
'rows.*.credit_amount' => ['required', 'integer', 'min:0'],
'rows.*.memo' => ['nullable', 'string', 'max:300'],
];
}
public function messages(): array
{
return [
'journal_date.required' => '전표일자를 입력하세요.',
'rows.required' => '분개 행을 입력하세요.',
'rows.min' => '최소 2개 이상의 분개 행이 필요합니다.',
'rows.*.side.required' => '차/대 구분을 선택하세요.',
'rows.*.side.in' => '차/대 구분이 올바르지 않습니다.',
'rows.*.account_subject_id.required' => '계정과목을 선택하세요.',
'rows.*.debit_amount.required' => '차변 금액을 입력하세요.',
'rows.*.credit_amount.required' => '대변 금액을 입력하세요.',
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\V1\GeneralJournalEntry;
use Illuminate\Foundation\Http\FormRequest;
class UpdateJournalRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'journal_memo' => ['sometimes', 'nullable', 'string', 'max:1000'],
'rows' => ['sometimes', 'array', 'min:1'],
'rows.*.side' => ['required_with:rows', 'in:debit,credit'],
'rows.*.account_subject_id' => ['required_with:rows', 'string', 'max:10'],
'rows.*.vendor_id' => ['nullable', 'integer'],
'rows.*.debit_amount' => ['required_with:rows', 'integer', 'min:0'],
'rows.*.credit_amount' => ['required_with:rows', 'integer', 'min:0'],
'rows.*.memo' => ['nullable', 'string', 'max:300'],
];
}
public function messages(): array
{
return [
'rows.*.side.required_with' => '차/대 구분을 선택하세요.',
'rows.*.side.in' => '차/대 구분이 올바르지 않습니다.',
'rows.*.account_subject_id.required_with' => '계정과목을 선택하세요.',
'rows.*.debit_amount.required_with' => '차변 금액을 입력하세요.',
'rows.*.credit_amount.required_with' => '대변 금액을 입력하세요.',
];
}
}

View File

@@ -22,6 +22,12 @@ public function rules(): array
'connection_type' => ['nullable', 'string', 'max:20'],
'connection_target' => ['nullable', 'string', 'max:255'],
'completion_type' => ['nullable', 'string', 'in:click_complete,selection_complete,inspection_complete'],
'options' => ['nullable', 'array'],
'options.inspection_setting' => ['nullable', 'array'],
'options.inspection_scope' => ['nullable', 'array'],
'options.inspection_scope.type' => ['nullable', 'string', 'in:all,sampling,group'],
'options.inspection_scope.sample_size' => ['nullable', 'integer', 'min:1'],
'options.inspection_scope.sample_base' => ['nullable', 'string', 'in:order,lot'],
];
}
@@ -36,6 +42,12 @@ public function attributes(): array
'connection_type' => '연결유형',
'connection_target' => '연결대상',
'completion_type' => '완료유형',
'options' => '옵션',
'options.inspection_setting' => '검사설정',
'options.inspection_scope' => '검사범위',
'options.inspection_scope.type' => '검사범위 유형',
'options.inspection_scope.sample_size' => '샘플 크기',
'options.inspection_scope.sample_base' => '샘플 기준',
];
}
}

View File

@@ -22,6 +22,12 @@ public function rules(): array
'connection_type' => ['nullable', 'string', 'max:20'],
'connection_target' => ['nullable', 'string', 'max:255'],
'completion_type' => ['nullable', 'string', 'in:click_complete,selection_complete,inspection_complete'],
'options' => ['nullable', 'array'],
'options.inspection_setting' => ['nullable', 'array'],
'options.inspection_scope' => ['nullable', 'array'],
'options.inspection_scope.type' => ['nullable', 'string', 'in:all,sampling,group'],
'options.inspection_scope.sample_size' => ['nullable', 'integer', 'min:1'],
'options.inspection_scope.sample_base' => ['nullable', 'string', 'in:order,lot'],
];
}
@@ -36,6 +42,12 @@ public function attributes(): array
'connection_type' => '연결유형',
'connection_target' => '연결대상',
'completion_type' => '완료유형',
'options' => '옵션',
'options.inspection_setting' => '검사설정',
'options.inspection_scope' => '검사범위',
'options.inspection_scope.type' => '검사범위 유형',
'options.inspection_scope.sample_size' => '샘플 크기',
'options.inspection_scope.sample_base' => '샘플 기준',
];
}
}

View File

@@ -15,7 +15,7 @@ public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'address' => ['nullable', 'string', 'max:255'],
'address' => ['nullable', 'string', 'max:500'],
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
'longitude' => ['nullable', 'numeric', 'between:-180,180'],
'is_active' => ['sometimes', 'boolean'],

View File

@@ -15,7 +15,7 @@ public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:100'],
'address' => ['nullable', 'string', 'max:255'],
'address' => ['nullable', 'string', 'max:500'],
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
'longitude' => ['nullable', 'numeric', 'between:-180,180'],
'is_active' => ['sometimes', 'boolean'],

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\VehicleDispatch;
use Illuminate\Foundation\Http\FormRequest;
class VehicleDispatchUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'freight_cost_type' => 'nullable|in:prepaid,collect',
'logistics_company' => 'nullable|string|max:100',
'arrival_datetime' => 'nullable|date',
'tonnage' => 'nullable|string|max:20',
'vehicle_no' => 'nullable|string|max:20',
'driver_contact' => 'nullable|string|max:50',
'remarks' => 'nullable|string',
'supply_amount' => 'nullable|numeric|min:0',
'vat' => 'nullable|numeric|min:0',
'total_amount' => 'nullable|numeric|min:0',
'status' => 'nullable|in:draft,completed',
];
}
}

View File

@@ -17,6 +17,8 @@ public function rules(): array
'inputs' => 'required|array|min:1',
'inputs.*.stock_lot_id' => 'required|integer',
'inputs.*.qty' => 'required|numeric|gt:0',
'inputs.*.bom_group_key' => 'sometimes|nullable|string|max:100',
'replace' => 'sometimes|boolean',
];
}

View File

@@ -39,6 +39,16 @@ public function rules(): array
'inspection_data.nonConformingContent' => 'nullable|string|max:1000',
'inspection_data.templateValues' => 'nullable|array',
'inspection_data.templateValues.*' => 'nullable',
// 절곡 제품별 검사 데이터
'inspection_data.products' => 'nullable|array',
'inspection_data.products.*.id' => 'required_with:inspection_data.products|string',
'inspection_data.products.*.bendingStatus' => ['nullable', Rule::in(['양호', '불량'])],
'inspection_data.products.*.lengthMeasured' => 'nullable|string|max:50',
'inspection_data.products.*.widthMeasured' => 'nullable|string|max:50',
'inspection_data.products.*.gapPoints' => 'nullable|array',
'inspection_data.products.*.gapPoints.*.point' => 'nullable|string',
'inspection_data.products.*.gapPoints.*.designValue' => 'nullable|string',
'inspection_data.products.*.gapPoints.*.measured' => 'nullable|string|max:50',
];
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models\Commons;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Holiday extends Model
{
use SoftDeletes;
protected $table = 'holidays';
protected $fillable = [
'tenant_id',
'start_date',
'end_date',
'name',
'type',
'is_recurring',
'memo',
'created_by',
'updated_by',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'is_recurring' => 'boolean',
];
public function scopeForTenant($query, int $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
public function scopeForYear($query, int $year)
{
return $query->whereYear('start_date', $year);
}
}

View File

@@ -73,6 +73,7 @@ class Document extends Model
'linkable_id',
'submitted_at',
'completed_at',
'rendered_html',
'created_by',
'updated_by',
'deleted_by',

View File

@@ -22,6 +22,7 @@ class DocumentTemplateSection extends Model
protected $fillable = [
'template_id',
'title',
'description',
'image_path',
'sort_order',
];

View File

@@ -22,6 +22,7 @@ class ProcessStep extends Model
'connection_type',
'connection_target',
'completion_type',
'options',
];
protected $casts = [
@@ -30,6 +31,7 @@ class ProcessStep extends Model
'needs_inspection' => 'boolean',
'is_active' => 'boolean',
'sort_order' => 'integer',
'options' => 'array',
];
/**

View File

@@ -6,6 +6,7 @@
use App\Models\Members\User;
use App\Models\Orders\Order;
use App\Models\Process;
use App\Models\Qualitys\Inspection;
use App\Models\Tenants\Department;
use App\Models\Tenants\Shipment;
use App\Traits\Auditable;
@@ -234,6 +235,14 @@ public function shipments(): HasMany
return $this->hasMany(Shipment::class);
}
/**
* 품질검사 (IQC/PQC/FQC)
*/
public function inspections(): HasMany
{
return $this->hasMany(Inspection::class);
}
/**
* 생성자
*/

View File

@@ -27,6 +27,7 @@ class WorkOrderMaterialInput extends Model
'work_order_item_id',
'stock_lot_id',
'item_id',
'bom_group_key',
'qty',
'input_by',
'input_at',

View File

@@ -4,6 +4,7 @@
use App\Models\Items\Item;
use App\Models\Members\User;
use App\Models\Production\WorkOrder;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -23,6 +24,7 @@
* @property string|null $inspection_date 검사일
* @property int|null $item_id 품목 ID
* @property string $lot_no LOT번호
* @property int|null $work_order_id 작업지시 ID (PQC/FQC용)
* @property int|null $inspector_id 검사자 ID
* @property array|null $meta 메타정보 (process_name, quantity, unit 등)
* @property array|null $items 검사항목 배열
@@ -47,6 +49,7 @@ class Inspection extends Model
'inspection_date',
'item_id',
'lot_no',
'work_order_id',
'inspector_id',
'meta',
'items',
@@ -92,6 +95,14 @@ class Inspection extends Model
// ===== Relationships =====
/**
* 작업지시 (PQC/FQC용)
*/
public function workOrder()
{
return $this->belongsTo(WorkOrder::class);
}
/**
* 품목
*/

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Models\Qualitys;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class PerformanceReport extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'performance_reports';
const STATUS_UNCONFIRMED = 'unconfirmed';
const STATUS_CONFIRMED = 'confirmed';
const STATUS_REPORTED = 'reported';
protected $fillable = [
'tenant_id',
'quality_document_id',
'year',
'quarter',
'confirmation_status',
'confirmed_date',
'confirmed_by',
'memo',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'confirmed_date' => 'date',
'year' => 'integer',
'quarter' => 'integer',
];
// ===== Relationships =====
public function qualityDocument()
{
return $this->belongsTo(QualityDocument::class);
}
public function confirmer()
{
return $this->belongsTo(User::class, 'confirmed_by');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
// ===== Status Helpers =====
public function isUnconfirmed(): bool
{
return $this->confirmation_status === self::STATUS_UNCONFIRMED;
}
public function isConfirmed(): bool
{
return $this->confirmation_status === self::STATUS_CONFIRMED;
}
public function isReported(): bool
{
return $this->confirmation_status === self::STATUS_REPORTED;
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace App\Models\Qualitys;
use App\Models\Members\User;
use App\Models\Orders\Client;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class QualityDocument extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'quality_documents';
const STATUS_RECEIVED = 'received';
const STATUS_IN_PROGRESS = 'in_progress';
const STATUS_COMPLETED = 'completed';
protected $fillable = [
'tenant_id',
'quality_doc_number',
'site_name',
'status',
'client_id',
'inspector_id',
'received_date',
'options',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'options' => 'array',
'received_date' => 'date',
];
// ===== Relationships =====
public function client()
{
return $this->belongsTo(Client::class, 'client_id');
}
public function inspector()
{
return $this->belongsTo(User::class, 'inspector_id');
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function documentOrders()
{
return $this->hasMany(QualityDocumentOrder::class);
}
public function locations()
{
return $this->hasMany(QualityDocumentLocation::class);
}
public function performanceReport()
{
return $this->hasOne(PerformanceReport::class);
}
// ===== 채번 =====
public static function generateDocNumber(int $tenantId): string
{
$prefix = 'KD-QD';
$yearMonth = now()->format('Ym');
$lastNo = static::withoutGlobalScopes()
->where('tenant_id', $tenantId)
->where('quality_doc_number', 'like', "{$prefix}-{$yearMonth}-%")
->orderByDesc('quality_doc_number')
->value('quality_doc_number');
if ($lastNo) {
$seq = (int) substr($lastNo, -4) + 1;
} else {
$seq = 1;
}
return sprintf('%s-%s-%04d', $prefix, $yearMonth, $seq);
}
// ===== Status Helpers =====
public function isReceived(): bool
{
return $this->status === self::STATUS_RECEIVED;
}
public function isInProgress(): bool
{
return $this->status === self::STATUS_IN_PROGRESS;
}
public function isCompleted(): bool
{
return $this->status === self::STATUS_COMPLETED;
}
public static function mapStatusToFrontend(string $status): string
{
return match ($status) {
self::STATUS_RECEIVED => 'reception',
self::STATUS_IN_PROGRESS => 'in_progress',
self::STATUS_COMPLETED => 'completed',
default => $status,
};
}
public static function mapStatusFromFrontend(string $status): string
{
return match ($status) {
'reception' => self::STATUS_RECEIVED,
default => $status,
};
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Models\Qualitys;
use App\Models\Documents\Document;
use App\Models\Orders\OrderItem;
use Illuminate\Database\Eloquent\Model;
class QualityDocumentLocation extends Model
{
protected $table = 'quality_document_locations';
const STATUS_PENDING = 'pending';
const STATUS_COMPLETED = 'completed';
protected $fillable = [
'quality_document_id',
'quality_document_order_id',
'order_item_id',
'post_width',
'post_height',
'change_reason',
'inspection_data',
'document_id',
'inspection_status',
];
protected $casts = [
'inspection_data' => 'array',
];
public function qualityDocument()
{
return $this->belongsTo(QualityDocument::class);
}
public function qualityDocumentOrder()
{
return $this->belongsTo(QualityDocumentOrder::class);
}
public function orderItem()
{
return $this->belongsTo(OrderItem::class);
}
public function document()
{
return $this->belongsTo(Document::class);
}
public function isPending(): bool
{
return $this->inspection_status === self::STATUS_PENDING;
}
public function isCompleted(): bool
{
return $this->inspection_status === self::STATUS_COMPLETED;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models\Qualitys;
use App\Models\Orders\Order;
use Illuminate\Database\Eloquent\Model;
class QualityDocumentOrder extends Model
{
protected $table = 'quality_document_orders';
protected $fillable = [
'quality_document_id',
'order_id',
];
public function qualityDocument()
{
return $this->belongsTo(QualityDocument::class);
}
public function order()
{
return $this->belongsTo(Order::class);
}
public function locations()
{
return $this->hasMany(QualityDocumentLocation::class);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class AccountCode extends Model
{
use BelongsToTenant;
protected $fillable = [
'tenant_id',
'code',
'name',
'category',
'sort_order',
'is_active',
];
protected $casts = [
'sort_order' => 'integer',
'is_active' => 'boolean',
];
// Categories
public const CATEGORY_ASSET = 'asset';
public const CATEGORY_LIABILITY = 'liability';
public const CATEGORY_CAPITAL = 'capital';
public const CATEGORY_REVENUE = 'revenue';
public const CATEGORY_EXPENSE = 'expense';
public const CATEGORIES = [
self::CATEGORY_ASSET => '자산',
self::CATEGORY_LIABILITY => '부채',
self::CATEGORY_CAPITAL => '자본',
self::CATEGORY_REVENUE => '수익',
self::CATEGORY_EXPENSE => '비용',
];
/**
* 활성 계정과목만 조회
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
}

View File

@@ -8,6 +8,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
@@ -55,6 +56,8 @@ class Approval extends Model
'completed_at',
'current_step',
'attachments',
'linkable_type',
'linkable_id',
'created_by',
'updated_by',
'deleted_by',
@@ -135,6 +138,14 @@ public function referenceSteps(): HasMany
->orderBy('step_order');
}
/**
* 연결 대상 (Document 등)
*/
public function linkable(): MorphTo
{
return $this->morphTo();
}
/**
* 생성자
*/

View File

@@ -31,6 +31,58 @@ class Bill extends Model
'created_by',
'updated_by',
'deleted_by',
// V8 확장 필드
'instrument_type',
'medium',
'bill_category',
'electronic_bill_no',
'registration_org',
'drawee',
'acceptance_status',
'acceptance_date',
'acceptance_refusal_date',
'acceptance_refusal_reason',
'endorsement',
'endorsement_order',
'storage_place',
'issuer_bank',
'is_discounted',
'discount_date',
'discount_bank',
'discount_rate',
'discount_amount',
'endorsement_date',
'endorsee',
'endorsement_reason',
'collection_bank',
'collection_request_date',
'collection_fee',
'collection_complete_date',
'collection_result',
'collection_deposit_date',
'collection_deposit_amount',
'settlement_bank',
'payment_method',
'actual_payment_date',
'payment_place',
'payment_place_detail',
'renewal_date',
'renewal_new_bill_no',
'renewal_reason',
'recourse_date',
'recourse_amount',
'recourse_target',
'recourse_reason',
'buyback_date',
'buyback_amount',
'buyback_bank',
'dishonored_date',
'dishonored_reason',
'has_protest',
'protest_date',
'recourse_notice_date',
'recourse_notice_deadline',
'is_split',
];
protected $casts = [
@@ -41,21 +93,57 @@ class Bill extends Model
'bank_account_id' => 'integer',
'installment_count' => 'integer',
'is_electronic' => 'boolean',
// V8 확장 casts
'acceptance_date' => 'date',
'acceptance_refusal_date' => 'date',
'discount_date' => 'date',
'discount_rate' => 'decimal:2',
'discount_amount' => 'decimal:2',
'endorsement_date' => 'date',
'collection_request_date' => 'date',
'collection_fee' => 'decimal:2',
'collection_complete_date' => 'date',
'collection_deposit_date' => 'date',
'collection_deposit_amount' => 'decimal:2',
'actual_payment_date' => 'date',
'renewal_date' => 'date',
'recourse_date' => 'date',
'recourse_amount' => 'decimal:2',
'buyback_date' => 'date',
'buyback_amount' => 'decimal:2',
'dishonored_date' => 'date',
'protest_date' => 'date',
'recourse_notice_date' => 'date',
'recourse_notice_deadline' => 'date',
'is_discounted' => 'boolean',
'has_protest' => 'boolean',
'is_split' => 'boolean',
];
/**
* 배열/JSON 변환 시 날짜 형식 지정
*/
/**
* 날짜 cast 필드 목록 (toArray에서 Y-m-d 형식 변환용)
*/
private const DATE_FIELDS = [
'issue_date', 'maturity_date',
'acceptance_date', 'acceptance_refusal_date',
'discount_date', 'endorsement_date',
'collection_request_date', 'collection_complete_date', 'collection_deposit_date',
'actual_payment_date',
'renewal_date', 'recourse_date', 'buyback_date',
'dishonored_date', 'protest_date', 'recourse_notice_date', 'recourse_notice_deadline',
];
public function toArray(): array
{
$array = parent::toArray();
// 날짜 필드를 Y-m-d 형식으로 변환
if (isset($array['issue_date']) && $this->issue_date) {
$array['issue_date'] = $this->issue_date->format('Y-m-d');
}
if (isset($array['maturity_date']) && $this->maturity_date) {
$array['maturity_date'] = $this->maturity_date->format('Y-m-d');
foreach (self::DATE_FIELDS as $field) {
if (isset($array[$field]) && $this->{$field}) {
$array[$field] = $this->{$field}->format('Y-m-d');
}
}
return $array;
@@ -69,14 +157,42 @@ public function toArray(): array
'issued' => '발행',
];
/**
* 증권종류
*/
public const INSTRUMENT_TYPES = [
'promissory' => '약속어음',
'exchange' => '환어음',
'cashierCheck' => '자기앞수표',
'currentCheck' => '당좌수표',
];
/**
* 수취 어음 상태 목록
*/
public const RECEIVED_STATUSES = [
'stored' => '보관중',
'endorsed' => '배서양도',
'discounted' => '할인',
'collectionRequest' => '추심의뢰',
'collectionComplete' => '추심완료',
'maturityDeposit' => '만기입금',
'paymentComplete' => '결제완료',
'dishonored' => '부도',
'renewed' => '개서',
'buyback' => '환매',
// 하위호환
'maturityAlert' => '만기입금(7일전)',
'maturityResult' => '만기결과',
'paymentComplete' => '결제완료',
];
/**
* 수취 수표 상태 목록
*/
public const RECEIVED_CHECK_STATUSES = [
'stored' => '보관중',
'endorsed' => '배서양도',
'deposited' => '입금',
'dishonored' => '부도',
];
@@ -85,10 +201,25 @@ public function toArray(): array
*/
public const ISSUED_STATUSES = [
'stored' => '보관중',
'issued' => '지급대기',
'maturityPayment' => '만기결제',
'paymentComplete' => '결제완료',
'dishonored' => '부도',
'renewed' => '개서',
// 하위호환
'maturityAlert' => '만기입금(7일전)',
'collectionRequest' => '추심의뢰',
'collectionComplete' => '추심완료',
'suing' => '추소중',
];
/**
* 발행 수표 상태 목록
*/
public const ISSUED_CHECK_STATUSES = [
'stored' => '보관중',
'issued' => '지급대기',
'cashed' => '현금화',
'dishonored' => '부도',
];
@@ -149,11 +280,25 @@ public function getBillTypeLabelAttribute(): string
*/
public function getStatusLabelAttribute(): string
{
$isCheck = in_array($this->instrument_type, ['cashierCheck', 'currentCheck']);
if ($this->bill_type === 'received') {
return self::RECEIVED_STATUSES[$this->status] ?? $this->status;
$statuses = $isCheck ? self::RECEIVED_CHECK_STATUSES : self::RECEIVED_STATUSES;
return $statuses[$this->status] ?? self::RECEIVED_STATUSES[$this->status] ?? $this->status;
}
return self::ISSUED_STATUSES[$this->status] ?? $this->status;
$statuses = $isCheck ? self::ISSUED_CHECK_STATUSES : self::ISSUED_STATUSES;
return $statuses[$this->status] ?? self::ISSUED_STATUSES[$this->status] ?? $this->status;
}
/**
* 증권종류 라벨
*/
public function getInstrumentTypeLabelAttribute(): string
{
return self::INSTRUMENT_TYPES[$this->instrument_type] ?? $this->instrument_type ?? '약속어음';
}
/**

View File

@@ -12,8 +12,10 @@ class BillInstallment extends Model
protected $fillable = [
'bill_id',
'type',
'installment_date',
'amount',
'counterparty',
'note',
'created_by',
];

View File

@@ -34,6 +34,7 @@ class ExpenseAccount extends Model
'vendor_name',
'payment_method',
'card_no',
'loan_id',
'created_by',
'updated_by',
'deleted_by',
@@ -53,6 +54,9 @@ class ExpenseAccount extends Model
public const TYPE_OFFICE = 'office';
// 세부 유형 상수 (접대비)
public const SUB_TYPE_GIFT_CERTIFICATE = 'gift_certificate';
// 세부 유형 상수 (복리후생)
public const SUB_TYPE_MEAL = 'meal';

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class JournalEntry extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'entry_no',
'entry_date',
'entry_type',
'description',
'total_debit',
'total_credit',
'status',
'source_type',
'source_key',
'created_by_name',
'attachment_note',
];
protected $casts = [
'entry_date' => 'date',
'total_debit' => 'integer',
'total_credit' => 'integer',
];
// Status
public const STATUS_DRAFT = 'draft';
public const STATUS_CONFIRMED = 'confirmed';
// Source type
public const SOURCE_MANUAL = 'manual';
public const SOURCE_BANK_TRANSACTION = 'bank_transaction';
// Entry type
public const TYPE_GENERAL = 'general';
/**
* 분개 행 관계
*/
public function lines(): HasMany
{
return $this->hasMany(JournalEntryLine::class)->orderBy('line_no');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class JournalEntryLine extends Model
{
use BelongsToTenant;
protected $fillable = [
'tenant_id',
'journal_entry_id',
'line_no',
'dc_type',
'account_code',
'account_name',
'trading_partner_id',
'trading_partner_name',
'debit_amount',
'credit_amount',
'description',
];
protected $casts = [
'line_no' => 'integer',
'debit_amount' => 'integer',
'credit_amount' => 'integer',
'trading_partner_id' => 'integer',
];
// DC Type
public const DC_DEBIT = 'debit';
public const DC_CREDIT = 'credit';
/**
* 전표 관계
*/
public function journalEntry(): BelongsTo
{
return $this->belongsTo(JournalEntry::class);
}
}

View File

@@ -53,6 +53,7 @@ class Leave extends Model
'status',
'approved_by',
'approved_at',
'approval_id',
'reject_reason',
'created_by',
'updated_by',
@@ -81,6 +82,18 @@ class Leave extends Model
public const TYPE_PARENTAL = 'parental'; // 육아
public const TYPE_BUSINESS_TRIP = 'business_trip'; // 출장
public const TYPE_REMOTE = 'remote'; // 재택근무
public const TYPE_FIELD_WORK = 'field_work'; // 외근
public const TYPE_EARLY_LEAVE = 'early_leave'; // 조퇴
public const TYPE_LATE_REASON = 'late_reason'; // 지각사유서
public const TYPE_ABSENT_REASON = 'absent_reason'; // 결근사유서
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
@@ -97,6 +110,45 @@ class Leave extends Model
self::TYPE_FAMILY,
self::TYPE_MATERNITY,
self::TYPE_PARENTAL,
self::TYPE_BUSINESS_TRIP,
self::TYPE_REMOTE,
self::TYPE_FIELD_WORK,
self::TYPE_EARLY_LEAVE,
self::TYPE_LATE_REASON,
self::TYPE_ABSENT_REASON,
];
// 그룹 상수
public const VACATION_TYPES = [
self::TYPE_ANNUAL, self::TYPE_HALF_AM, self::TYPE_HALF_PM,
self::TYPE_SICK, self::TYPE_FAMILY, self::TYPE_MATERNITY, self::TYPE_PARENTAL,
];
public const ATTENDANCE_REQUEST_TYPES = [
self::TYPE_BUSINESS_TRIP, self::TYPE_REMOTE, self::TYPE_FIELD_WORK, self::TYPE_EARLY_LEAVE,
];
public const REASON_REPORT_TYPES = [
self::TYPE_LATE_REASON, self::TYPE_ABSENT_REASON,
];
// 유형 → 결재양식코드 매핑
public const FORM_CODE_MAP = [
'annual' => 'leave', 'half_am' => 'leave', 'half_pm' => 'leave',
'sick' => 'leave', 'family' => 'leave', 'maternity' => 'leave', 'parental' => 'leave',
'business_trip' => 'attendance_request', 'remote' => 'attendance_request',
'field_work' => 'attendance_request', 'early_leave' => 'attendance_request',
'late_reason' => 'reason_report', 'absent_reason' => 'reason_report',
];
// 유형 → 근태상태 매핑 (승인 시 Attendance에 반영할 상태)
public const ATTENDANCE_STATUS_MAP = [
'annual' => 'vacation', 'half_am' => 'vacation', 'half_pm' => 'vacation',
'sick' => 'vacation', 'family' => 'vacation', 'maternity' => 'vacation', 'parental' => 'vacation',
'business_trip' => 'businessTrip', 'remote' => 'remote', 'field_work' => 'fieldWork',
'early_leave' => null,
'late_reason' => null,
'absent_reason' => null,
];
public const STATUSES = [
@@ -252,6 +304,12 @@ public function getLeaveTypeLabelAttribute(): string
self::TYPE_FAMILY => '경조사',
self::TYPE_MATERNITY => '출산휴가',
self::TYPE_PARENTAL => '육아휴직',
self::TYPE_BUSINESS_TRIP => '출장',
self::TYPE_REMOTE => '재택근무',
self::TYPE_FIELD_WORK => '외근',
self::TYPE_EARLY_LEAVE => '조퇴',
self::TYPE_LATE_REASON => '지각사유서',
self::TYPE_ABSENT_REASON => '결근사유서',
default => $this->leave_type,
};
}

View File

@@ -2,6 +2,7 @@
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
@@ -27,6 +28,12 @@ class Loan extends Model
public const STATUS_PARTIAL = 'partial'; // 부분정산
public const STATUS_HOLDING = 'holding'; // 보유 (상품권)
public const STATUS_USED = 'used'; // 사용 (상품권)
public const STATUS_DISPOSED = 'disposed'; // 폐기 (상품권)
/**
* 상태 목록
*/
@@ -34,6 +41,40 @@ class Loan extends Model
self::STATUS_OUTSTANDING,
self::STATUS_SETTLED,
self::STATUS_PARTIAL,
self::STATUS_HOLDING,
self::STATUS_USED,
self::STATUS_DISPOSED,
];
/**
* 카테고리 상수 (D1.7 기획서)
*/
public const CATEGORY_CARD = 'card'; // 카드
public const CATEGORY_CONGRATULATORY = 'congratulatory'; // 경조사
public const CATEGORY_GIFT_CERTIFICATE = 'gift_certificate'; // 상품권
public const CATEGORY_ENTERTAINMENT = 'entertainment'; // 접대비
/**
* 카테고리 목록
*/
public const CATEGORIES = [
self::CATEGORY_CARD,
self::CATEGORY_CONGRATULATORY,
self::CATEGORY_GIFT_CERTIFICATE,
self::CATEGORY_ENTERTAINMENT,
];
/**
* 카테고리 라벨 매핑
*/
public const CATEGORY_LABELS = [
self::CATEGORY_CARD => '카드',
self::CATEGORY_CONGRATULATORY => '경조사',
self::CATEGORY_GIFT_CERTIFICATE => '상품권',
self::CATEGORY_ENTERTAINMENT => '접대비',
];
/**
@@ -71,6 +112,8 @@ class Loan extends Model
'settlement_date',
'settlement_amount',
'status',
'category',
'metadata',
'withdrawal_id',
'created_by',
'updated_by',
@@ -82,6 +125,7 @@ class Loan extends Model
'settlement_date' => 'date',
'amount' => 'decimal:2',
'settlement_amount' => 'decimal:2',
'metadata' => 'array',
];
// =========================================================================
@@ -133,10 +177,21 @@ public function getStatusLabelAttribute(): string
self::STATUS_OUTSTANDING => '미정산',
self::STATUS_SETTLED => '정산완료',
self::STATUS_PARTIAL => '부분정산',
self::STATUS_HOLDING => '보유',
self::STATUS_USED => '사용',
self::STATUS_DISPOSED => '폐기',
default => $this->status,
};
}
/**
* 카테고리 라벨
*/
public function getCategoryLabelAttribute(): string
{
return self::CATEGORY_LABELS[$this->category] ?? $this->category ?? '카드';
}
/**
* 미정산 잔액
*/
@@ -164,19 +219,33 @@ public function getElapsedDaysAttribute(): int
// =========================================================================
/**
* 수정 가능 여부 (미정산 상태)
* 수정 가능 여부 (미정산 상태 또는 상품권)
*/
public function isEditable(): bool
{
return $this->status === self::STATUS_OUTSTANDING;
if ($this->category === self::CATEGORY_GIFT_CERTIFICATE) {
return true;
}
return in_array($this->status, [
self::STATUS_OUTSTANDING,
self::STATUS_HOLDING,
]);
}
/**
* 삭제 가능 여부 (미정산 상태만)
* 삭제 가능 여부 (미정산/보유 상태 또는 상품권)
*/
public function isDeletable(): bool
{
return $this->status === self::STATUS_OUTSTANDING;
if ($this->category === self::CATEGORY_GIFT_CERTIFICATE) {
return true;
}
return in_array($this->status, [
self::STATUS_OUTSTANDING,
self::STATUS_HOLDING,
]);
}
/**

View File

@@ -134,6 +134,14 @@ public function items(): HasMany
return $this->hasMany(ShipmentItem::class)->orderBy('seq');
}
/**
* 배차정보 관계
*/
public function vehicleDispatches(): HasMany
{
return $this->hasMany(ShipmentVehicleDispatch::class)->orderBy('seq');
}
/**
* 거래처 관계
*/

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Models\Tenants;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ShipmentVehicleDispatch extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'shipment_id',
'seq',
'logistics_company',
'arrival_datetime',
'tonnage',
'vehicle_no',
'driver_contact',
'remarks',
'options',
];
protected $casts = [
'seq' => 'integer',
'shipment_id' => 'integer',
'arrival_datetime' => 'datetime',
'options' => 'array',
];
/**
* 출하 관계
*/
public function shipment(): BelongsTo
{
return $this->belongsTo(Shipment::class);
}
/**
* 다음 순번 가져오기
*/
public static function getNextSeq(int $shipmentId): int
{
$maxSeq = static::where('shipment_id', $shipmentId)->max('seq');
return ($maxSeq ?? 0) + 1;
}
}

View File

@@ -44,6 +44,7 @@ class TenantUserProfile extends Model
'employee_status',
'manager_user_id',
'json_extra',
'worker_type',
'profile_photo_path',
'display_name',
];

View File

@@ -2,7 +2,7 @@
namespace App\Models\Tenants;
use App\Models\Users\User;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Services;
use App\Models\Tenants\AccountCode;
use App\Models\Tenants\JournalEntryLine;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class AccountCodeService extends Service
{
/**
* 계정과목 목록 조회
*/
public function index(array $params): array
{
$tenantId = $this->tenantId();
$query = AccountCode::query()
->where('tenant_id', $tenantId);
// 검색 (코드/이름)
if (! empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
});
}
// 분류 필터
if (! empty($params['category'])) {
$query->where('category', $params['category']);
}
return $query->orderBy('sort_order')->orderBy('code')->get()->toArray();
}
/**
* 계정과목 등록
*/
public function store(array $data): AccountCode
{
$tenantId = $this->tenantId();
// 중복 코드 체크
$exists = AccountCode::query()
->where('tenant_id', $tenantId)
->where('code', $data['code'])
->exists();
if ($exists) {
throw new BadRequestHttpException(__('error.account_subject.duplicate_code'));
}
$accountCode = new AccountCode;
$accountCode->tenant_id = $tenantId;
$accountCode->code = $data['code'];
$accountCode->name = $data['name'];
$accountCode->category = $data['category'] ?? null;
$accountCode->sort_order = $data['sort_order'] ?? 0;
$accountCode->is_active = true;
$accountCode->save();
return $accountCode;
}
/**
* 계정과목 활성/비활성 토글
*/
public function toggleStatus(int $id, bool $isActive): AccountCode
{
$tenantId = $this->tenantId();
$accountCode = AccountCode::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
$accountCode->is_active = $isActive;
$accountCode->save();
return $accountCode;
}
/**
* 계정과목 삭제 (사용 중이면 차단)
*/
public function destroy(int $id): bool
{
$tenantId = $this->tenantId();
$accountCode = AccountCode::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
// 전표에서 사용 중인지 확인
$inUse = JournalEntryLine::query()
->where('tenant_id', $tenantId)
->where('account_code', $accountCode->code)
->exists();
if ($inUse) {
throw new BadRequestHttpException(__('error.account_subject.in_use'));
}
$accountCode->delete();
return true;
}
}

View File

@@ -323,7 +323,7 @@ private function getReceivableData(int $tenantId, Carbon $reportDate): array
private function callGeminiApi(array $inputData): array
{
$apiKey = config('services.gemini.api_key');
$model = config('services.gemini.model', 'gemini-2.0-flash');
$model = config('services.gemini.model', 'gemini-2.5-flash');
$baseUrl = config('services.gemini.base_url');
if (empty($apiKey)) {

View File

@@ -2,6 +2,7 @@
namespace App\Services;
use App\Models\Documents\Document;
use App\Models\Tenants\Approval;
use App\Models\Tenants\ApprovalForm;
use App\Models\Tenants\ApprovalLine;
@@ -446,6 +447,14 @@ public function inbox(array $params): LengthAwarePaginator
}
}
// 날짜 범위 필터
if (! empty($params['start_date'])) {
$query->whereDate('created_at', '>=', $params['start_date']);
}
if (! empty($params['end_date'])) {
$query->whereDate('created_at', '<=', $params['end_date']);
}
// 정렬
$sortBy = $params['sort_by'] ?? 'created_at';
$sortDir = $params['sort_dir'] ?? 'desc';
@@ -559,7 +568,7 @@ public function show(int $id): Approval
{
$tenantId = $this->tenantId();
return Approval::query()
$approval = Approval::query()
->where('tenant_id', $tenantId)
->with([
'form:id,name,code,category,template',
@@ -571,6 +580,19 @@ public function show(int $id): Approval
'steps.approver.tenantProfile.department:id,name',
])
->findOrFail($id);
// Document 브릿지: 연결된 문서 데이터 로딩
if ($approval->linkable_type === Document::class) {
$approval->load([
'linkable.template',
'linkable.template.approvalLines',
'linkable.data',
'linkable.approvals.user:id,name',
'linkable.attachments',
]);
}
return $approval;
}
/**
@@ -834,6 +856,9 @@ public function approve(int $id, ?string $comment = null): Approval
$approval->updated_by = $userId;
$approval->save();
// Document 브릿지 동기화
$this->syncToLinkedDocument($approval);
return $approval->fresh([
'form:id,name,code,category',
'drafter:id,name',
@@ -887,6 +912,9 @@ public function reject(int $id, string $comment): Approval
$approval->updated_by = $userId;
$approval->save();
// Document 브릿지 동기화
$this->syncToLinkedDocument($approval);
return $approval->fresh([
'form:id,name,code,category',
'drafter:id,name',
@@ -926,6 +954,9 @@ public function cancel(int $id): Approval
$approval->updated_by = $userId;
$approval->save();
// Document 브릿지 동기화 (steps 삭제 전에 실행)
$this->syncToLinkedDocument($approval);
// 결재 단계들 삭제
$approval->steps()->delete();
@@ -936,6 +967,57 @@ public function cancel(int $id): Approval
});
}
/**
* Approval → Document 브릿지 동기화
* 결재 승인/반려/회수 시 연결된 Document의 상태와 결재란을 동기화
*/
private function syncToLinkedDocument(Approval $approval): void
{
if ($approval->linkable_type !== Document::class) {
return;
}
$document = Document::find($approval->linkable_id);
if (! $document) {
return;
}
// approval_steps → document_approvals 동기화 (승인자 이름/시각 반영)
foreach ($approval->steps as $step) {
if ($step->status === ApprovalStep::STATUS_PENDING) {
continue;
}
$docApproval = $document->approvals()
->where('step', $step->step_order)
->first();
if ($docApproval) {
$docApproval->update([
'status' => strtoupper($step->status),
'acted_at' => $step->acted_at,
'comment' => $step->comment,
]);
}
}
// Document 전체 상태 동기화
$documentStatus = match ($approval->status) {
Approval::STATUS_APPROVED => Document::STATUS_APPROVED,
Approval::STATUS_REJECTED => Document::STATUS_REJECTED,
Approval::STATUS_CANCELLED => Document::STATUS_CANCELLED,
default => Document::STATUS_PENDING,
};
$document->update([
'status' => $documentStatus,
'completed_at' => in_array($approval->status, [
Approval::STATUS_APPROVED,
Approval::STATUS_REJECTED,
]) ? now() : null,
]);
}
/**
* 참조 열람 처리
*/

View File

@@ -48,6 +48,16 @@ public function index(array $params): LengthAwarePaginator
$query->where('client_id', $params['client_id']);
}
// 증권종류 필터
if (! empty($params['instrument_type'])) {
$query->where('instrument_type', $params['instrument_type']);
}
// 매체 필터
if (! empty($params['medium'])) {
$query->where('medium', $params['medium']);
}
// 전자어음 필터
if (isset($params['is_electronic']) && $params['is_electronic'] !== '') {
$query->where('is_electronic', (bool) $params['is_electronic']);
@@ -113,32 +123,23 @@ public function store(array $data): Bill
$bill->client_name = $data['client_name'] ?? null;
$bill->amount = $data['amount'];
$bill->issue_date = $data['issue_date'];
$bill->maturity_date = $data['maturity_date'];
$bill->maturity_date = $data['maturity_date'] ?? null;
$bill->status = $data['status'] ?? 'stored';
$bill->reason = $data['reason'] ?? null;
$bill->installment_count = $data['installment_count'] ?? 0;
$bill->note = $data['note'] ?? null;
$bill->is_electronic = $data['is_electronic'] ?? false;
$bill->bank_account_id = $data['bank_account_id'] ?? null;
// V8 확장 필드
$this->assignV8Fields($bill, $data);
$bill->created_by = $userId;
$bill->updated_by = $userId;
$bill->save();
// 차수 관리 저장
if (! empty($data['installments'])) {
foreach ($data['installments'] as $installment) {
BillInstallment::create([
'bill_id' => $bill->id,
'installment_date' => $installment['date'],
'amount' => $installment['amount'],
'note' => $installment['note'] ?? null,
'created_by' => $userId,
]);
}
// 차수 카운트 업데이트
$bill->installment_count = count($data['installments']);
$bill->save();
}
$this->syncInstallments($bill, $data['installments'] ?? [], $userId);
return $bill->load(['client:id,name', 'bankAccount:id,bank_name,account_name', 'installments']);
});
@@ -157,6 +158,7 @@ public function update(int $id, array $data): Bill
->where('tenant_id', $tenantId)
->findOrFail($id);
// 기존 필드
if (isset($data['bill_number'])) {
$bill->bill_number = $data['bill_number'];
}
@@ -175,7 +177,7 @@ public function update(int $id, array $data): Bill
if (isset($data['issue_date'])) {
$bill->issue_date = $data['issue_date'];
}
if (isset($data['maturity_date'])) {
if (array_key_exists('maturity_date', $data)) {
$bill->maturity_date = $data['maturity_date'];
}
if (isset($data['status'])) {
@@ -194,27 +196,15 @@ public function update(int $id, array $data): Bill
$bill->bank_account_id = $data['bank_account_id'];
}
// V8 확장 필드
$this->assignV8Fields($bill, $data);
$bill->updated_by = $userId;
$bill->save();
// 차수 관리 업데이트 (전체 교체)
if (isset($data['installments'])) {
// 기존 차수 삭제
$bill->installments()->delete();
// 새 차수 추가
foreach ($data['installments'] as $installment) {
BillInstallment::create([
'bill_id' => $bill->id,
'installment_date' => $installment['date'],
'amount' => $installment['amount'],
'note' => $installment['note'] ?? null,
'created_by' => $userId,
]);
}
// 차수 카운트 업데이트
$bill->installment_count = count($data['installments']);
$bill->save();
$this->syncInstallments($bill, $data['installments'], $userId);
}
return $bill->fresh(['client:id,name', 'bankAccount:id,bank_name,account_name', 'installments']);
@@ -440,6 +430,68 @@ public function dashboardDetail(): array
];
}
/**
* V8 확장 필드를 Bill 모델에 할당
*/
private function assignV8Fields(Bill $bill, array $data): void
{
$v8Fields = [
'instrument_type', 'medium', 'bill_category',
'electronic_bill_no', 'registration_org',
'drawee', 'acceptance_status', 'acceptance_date',
'acceptance_refusal_date', 'acceptance_refusal_reason',
'endorsement', 'endorsement_order', 'storage_place', 'issuer_bank',
'is_discounted', 'discount_date', 'discount_bank', 'discount_rate', 'discount_amount',
'endorsement_date', 'endorsee', 'endorsement_reason',
'collection_bank', 'collection_request_date', 'collection_fee',
'collection_complete_date', 'collection_result', 'collection_deposit_date', 'collection_deposit_amount',
'settlement_bank', 'payment_method', 'actual_payment_date',
'payment_place', 'payment_place_detail',
'renewal_date', 'renewal_new_bill_no', 'renewal_reason',
'recourse_date', 'recourse_amount', 'recourse_target', 'recourse_reason',
'buyback_date', 'buyback_amount', 'buyback_bank',
'dishonored_date', 'dishonored_reason', 'has_protest', 'protest_date',
'recourse_notice_date', 'recourse_notice_deadline',
'is_split',
];
foreach ($v8Fields as $field) {
if (array_key_exists($field, $data)) {
$bill->{$field} = $data[$field];
}
}
}
/**
* 차수(이력) 동기화 — 기존 삭제 후 새로 생성
*/
private function syncInstallments(Bill $bill, array $installments, int $userId): void
{
if (empty($installments)) {
return;
}
// 기존 차수 삭제
$bill->installments()->delete();
// 새 차수 추가
foreach ($installments as $installment) {
BillInstallment::create([
'bill_id' => $bill->id,
'type' => $installment['type'] ?? 'other',
'installment_date' => $installment['date'],
'amount' => $installment['amount'],
'counterparty' => $installment['counterparty'] ?? null,
'note' => $installment['note'] ?? null,
'created_by' => $userId,
]);
}
// 차수 카운트 업데이트
$bill->installment_count = count($installments);
$bill->save();
}
/**
* 어음번호 자동 생성
*/

View File

@@ -0,0 +1,180 @@
<?php
namespace App\Services;
use App\Models\Commons\Holiday;
use Symfony\Component\HttpKernel\Exception\HttpException;
class CalendarScheduleService extends Service
{
/**
* 연도별 일정 목록 조회
*/
public function list(int $year, ?string $type = null): array
{
$query = Holiday::forTenant($this->tenantId())
->forYear($year)
->orderBy('start_date');
if ($type) {
$query->where('type', $type);
}
return $query->get()->map(function ($h) {
return [
'id' => $h->id,
'name' => $h->name,
'type' => $h->type,
'start_date' => $h->start_date->format('Y-m-d'),
'end_date' => $h->end_date->format('Y-m-d'),
'days' => $h->start_date->diffInDays($h->end_date) + 1,
'is_recurring' => $h->is_recurring,
'memo' => $h->memo,
'created_at' => $h->created_at?->toIso8601String(),
'updated_at' => $h->updated_at?->toIso8601String(),
];
})->all();
}
/**
* 통계 조회
*/
public function stats(int $year): array
{
$tenantId = $this->tenantId();
$holidays = Holiday::forTenant($tenantId)->forYear($year)->get();
$totalDays = $holidays->sum(function ($h) {
return $h->start_date->diffInDays($h->end_date) + 1;
});
return [
'total_count' => $holidays->count(),
'total_holiday_days' => $totalDays,
'public_holiday_count' => $holidays->where('type', 'public_holiday')->count(),
];
}
/**
* 단건 조회
*/
public function show(int $id): array
{
$h = Holiday::forTenant($this->tenantId())->findOrFail($id);
return [
'id' => $h->id,
'name' => $h->name,
'type' => $h->type,
'start_date' => $h->start_date->format('Y-m-d'),
'end_date' => $h->end_date->format('Y-m-d'),
'days' => $h->start_date->diffInDays($h->end_date) + 1,
'is_recurring' => $h->is_recurring,
'memo' => $h->memo,
'created_at' => $h->created_at?->toIso8601String(),
'updated_at' => $h->updated_at?->toIso8601String(),
];
}
/**
* 등록
*/
public function store(array $data): array
{
$tenantId = $this->tenantId();
$exists = Holiday::forTenant($tenantId)
->where('start_date', $data['start_date'])
->where('end_date', $data['end_date'])
->where('name', $data['name'])
->exists();
if ($exists) {
throw new HttpException(422, __('error.duplicate'));
}
$holiday = Holiday::create([
'tenant_id' => $tenantId,
'start_date' => $data['start_date'],
'end_date' => $data['end_date'],
'name' => $data['name'],
'type' => $data['type'] ?? 'public_holiday',
'is_recurring' => $data['is_recurring'] ?? false,
'memo' => $data['memo'] ?? null,
'created_by' => $this->apiUserId(),
]);
return $this->show($holiday->id);
}
/**
* 수정
*/
public function update(int $id, array $data): array
{
$holiday = Holiday::forTenant($this->tenantId())->findOrFail($id);
$holiday->update([
'start_date' => $data['start_date'],
'end_date' => $data['end_date'],
'name' => $data['name'],
'type' => $data['type'],
'is_recurring' => $data['is_recurring'] ?? false,
'memo' => $data['memo'] ?? null,
'updated_by' => $this->apiUserId(),
]);
return $this->show($id);
}
/**
* 삭제
*/
public function delete(int $id): void
{
$holiday = Holiday::forTenant($this->tenantId())->findOrFail($id);
$holiday->delete();
}
/**
* 대량 등록
*/
public function bulkStore(array $schedules): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$count = 0;
$skipped = 0;
foreach ($schedules as $item) {
$exists = Holiday::forTenant($tenantId)
->where('start_date', $item['start_date'])
->where('end_date', $item['end_date'])
->where('name', $item['name'])
->exists();
if ($exists) {
$skipped++;
continue;
}
Holiday::create([
'tenant_id' => $tenantId,
'start_date' => $item['start_date'],
'end_date' => $item['end_date'],
'name' => $item['name'],
'type' => $item['type'] ?? 'public_holiday',
'is_recurring' => $item['is_recurring'] ?? false,
'memo' => $item['memo'] ?? null,
'created_by' => $userId,
]);
$count++;
}
return [
'created' => $count,
'skipped' => $skipped,
];
}
}

View File

@@ -226,6 +226,78 @@ private function getLeaveSchedules(
});
}
/**
* 일정 등록
*/
public function createSchedule(array $data): array
{
$schedule = Schedule::create([
'tenant_id' => $this->tenantId(),
'title' => $data['title'],
'description' => $data['description'] ?? null,
'start_date' => $data['start_date'],
'end_date' => $data['end_date'],
'start_time' => $data['start_time'] ?? null,
'end_time' => $data['end_time'] ?? null,
'is_all_day' => $data['is_all_day'] ?? true,
'type' => Schedule::TYPE_EVENT,
'color' => $data['color'] ?? null,
'is_active' => true,
'created_by' => $this->apiUserId(),
]);
return [
'id' => $schedule->id,
'title' => $schedule->title,
'start_date' => $schedule->start_date?->format('Y-m-d'),
'end_date' => $schedule->end_date?->format('Y-m-d'),
];
}
/**
* 일정 수정
*/
public function updateSchedule(int $id, array $data): array
{
$schedule = Schedule::where('tenant_id', $this->tenantId())
->findOrFail($id);
$schedule->update([
'title' => $data['title'],
'description' => $data['description'] ?? null,
'start_date' => $data['start_date'],
'end_date' => $data['end_date'],
'start_time' => $data['start_time'] ?? null,
'end_time' => $data['end_time'] ?? null,
'is_all_day' => $data['is_all_day'] ?? true,
'color' => $data['color'] ?? null,
'updated_by' => $this->apiUserId(),
]);
return [
'id' => $schedule->id,
'title' => $schedule->title,
'start_date' => $schedule->start_date?->format('Y-m-d'),
'end_date' => $schedule->end_date?->format('Y-m-d'),
];
}
/**
* 일정 삭제 (소프트 삭제)
*/
public function deleteSchedule(int $id): array
{
$schedule = Schedule::where('tenant_id', $this->tenantId())
->findOrFail($id);
$schedule->update(['deleted_by' => $this->apiUserId()]);
$schedule->delete();
return [
'id' => $schedule->id,
];
}
/**
* 범용 일정 조회 (본사 공통 + 테넌트 일정)
*/

View File

@@ -22,6 +22,8 @@ public function index(array $params)
$q = trim((string) ($params['q'] ?? ''));
$onlyActive = $params['only_active'] ?? null;
$clientType = $params['client_type'] ?? null;
$startDate = $params['start_date'] ?? null;
$endDate = $params['end_date'] ?? null;
$query = Client::query()->where('tenant_id', $tenantId);
@@ -43,6 +45,14 @@ public function index(array $params)
$query->whereIn('client_type', $types);
}
// 등록일 기간 필터
if ($startDate) {
$query->whereDate('created_at', '>=', $startDate);
}
if ($endDate) {
$query->whereDate('created_at', '<=', $endDate);
}
$query->orderBy('client_code')->orderBy('id');
$paginator = $query->paginate($size, ['*'], 'page', $page);

View File

@@ -5,6 +5,9 @@
use App\Models\Tenants\BankAccount;
use App\Models\Tenants\Bill;
use App\Models\Tenants\Deposit;
use App\Models\Tenants\ExpectedExpense;
use App\Models\Tenants\Purchase;
use App\Models\Tenants\Sale;
use App\Models\Tenants\Withdrawal;
use Carbon\Carbon;
@@ -155,6 +158,11 @@ public function summary(array $params): array
: null;
$operatingStability = $this->getOperatingStability($operatingMonths);
// 기획서 D1.7 자금현황 카드용 필드
$receivableBalance = $this->calculateReceivableBalance($tenantId, $date);
$payableBalance = $this->calculatePayableBalance($tenantId);
$monthlyExpenseTotal = $this->calculateMonthlyExpenseTotal($tenantId, $date);
return [
'date' => $date->format('Y-m-d'),
'day_of_week' => $date->locale('ko')->dayName,
@@ -167,9 +175,138 @@ public function summary(array $params): array
'monthly_operating_expense' => $monthlyOperatingExpense,
'operating_months' => $operatingMonths,
'operating_stability' => $operatingStability,
// 자금현황 카드용
'receivable_balance' => $receivableBalance,
'payable_balance' => $payableBalance,
'monthly_expense_total' => $monthlyExpenseTotal,
];
}
/**
* 엑셀 내보내기용 데이터 조합
* DailyReportExport가 기대하는 구조로 변환
*/
public function exportData(array $params): array
{
$date = isset($params['date']) ? Carbon::parse($params['date']) : Carbon::today();
$dateStr = $date->format('Y-m-d');
// 화면과 동일한 계좌별 현황 데이터 재사용
$dailyAccounts = $this->dailyAccounts($params);
// KRW 계좌 합산 (화면 합계와 동일)
$carryover = 0;
$totalIncome = 0;
$totalExpense = 0;
$totalBalance = 0;
$details = [];
foreach ($dailyAccounts as $account) {
$carryover += $account['carryover'];
$totalIncome += $account['income'];
$totalExpense += $account['expense'];
$totalBalance += $account['balance'];
// 계좌별 상세 내역
if ($account['income'] > 0) {
$details[] = [
'type_label' => '입금',
'client_name' => $account['category'],
'account_code' => '-',
'deposit_amount' => $account['income'],
'withdrawal_amount' => 0,
'description' => '',
];
}
if ($account['expense'] > 0) {
$details[] = [
'type_label' => '출금',
'client_name' => $account['category'],
'account_code' => '-',
'deposit_amount' => 0,
'withdrawal_amount' => $account['expense'],
'description' => '',
];
}
}
// 어음 및 외상매출채권 현황
$noteReceivables = $this->noteReceivables($params);
return [
'date' => $dateStr,
'previous_balance' => $carryover,
'daily_deposit' => $totalIncome,
'daily_withdrawal' => $totalExpense,
'current_balance' => $totalBalance,
'details' => $details,
'note_receivables' => $noteReceivables,
];
}
/**
* 미수금 잔액 계산
* = 전체 매출 - 전체 입금 - 전체 수취어음 (기준일까지)
* ReceivablesService.getTotalCarryForwardBalance() 동일 로직
*/
private function calculateReceivableBalance(int $tenantId, Carbon $date): float
{
$endDate = $date->format('Y-m-d');
$totalSales = Sale::where('tenant_id', $tenantId)
->whereNotNull('client_id')
->where('sale_date', '<=', $endDate)
->sum('total_amount');
$totalDeposits = Deposit::where('tenant_id', $tenantId)
->whereNotNull('client_id')
->where('deposit_date', '<=', $endDate)
->sum('amount');
$totalBills = Bill::where('tenant_id', $tenantId)
->whereNotNull('client_id')
->where('bill_type', 'received')
->where('issue_date', '<=', $endDate)
->sum('amount');
return (float) ($totalSales - $totalDeposits - $totalBills);
}
/**
* 미지급금 잔액 계산
* = 미지급 상태(pending, partial, overdue)인 ExpectedExpense 합계
*/
private function calculatePayableBalance(int $tenantId): float
{
return (float) ExpectedExpense::where('tenant_id', $tenantId)
->whereIn('payment_status', ['pending', 'partial', 'overdue'])
->sum('amount');
}
/**
* 당월 예상 지출 합계 계산
* = 당월 매입(Purchase) + 당월 예상지출(ExpectedExpense)
*/
private function calculateMonthlyExpenseTotal(int $tenantId, Carbon $date): float
{
$startOfMonth = $date->copy()->startOfMonth()->format('Y-m-d');
$endOfMonth = $date->copy()->endOfMonth()->format('Y-m-d');
// 당월 매입 합계
$purchaseTotal = Purchase::where('tenant_id', $tenantId)
->whereBetween('purchase_date', [$startOfMonth, $endOfMonth])
->sum('total_amount');
// 당월 예상 지출 합계 (매입 외: 카드, 어음, 급여, 임대료 등)
$expectedExpenseTotal = ExpectedExpense::where('tenant_id', $tenantId)
->whereBetween('expected_payment_date', [$startOfMonth, $endOfMonth])
->sum('amount');
return (float) ($purchaseTotal + $expectedExpenseTotal);
}
/**
* 직전 3개월 평균 월 운영비 계산
*

View File

@@ -0,0 +1,740 @@
<?php
namespace App\Services;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/**
* CEO 대시보드 섹션별 요약 서비스
*
* 6개 섹션: 매출, 매입, 생산, 미출고, 시공, 근태
* sam_stat 우선 조회 → fallback 원본 DB
*/
class DashboardCeoService extends Service
{
// ─── 1. 매출 현황 ───────────────────────────────
/**
* 매출 현황 요약
*/
public function salesSummary(): array
{
$tenantId = $this->tenantId();
$now = Carbon::now();
$year = $now->year;
$month = $now->month;
$today = $now->format('Y-m-d');
// 누적 매출 (연초~오늘)
$cumulativeSales = DB::table('sales')
->where('tenant_id', $tenantId)
->whereYear('sale_date', $year)
->where('sale_date', '<=', $today)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
// 당월 매출
$monthlySales = DB::table('sales')
->where('tenant_id', $tenantId)
->whereYear('sale_date', $year)
->whereMonth('sale_date', $month)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
// 전년 동월 매출 (YoY)
$lastYearMonthlySales = DB::table('sales')
->where('tenant_id', $tenantId)
->whereYear('sale_date', $year - 1)
->whereMonth('sale_date', $month)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
$yoyChange = $lastYearMonthlySales > 0
? round((($monthlySales - $lastYearMonthlySales) / $lastYearMonthlySales) * 100, 1)
: 0;
// 달성률 (당월 매출 / 전년 동월 매출 * 100)
$achievementRate = $lastYearMonthlySales > 0
? round(($monthlySales / $lastYearMonthlySales) * 100, 0)
: 0;
// 월별 추이 (1~12월)
$monthlyTrend = $this->getSalesMonthlyTrend($tenantId, $year);
// 거래처별 매출 (상위 5개)
$clientSales = $this->getSalesClientRanking($tenantId, $year);
// 일별 매출 내역 (최근 10건)
$dailyItems = $this->getSalesDailyItems($tenantId, $today);
// 일별 합계
$dailyTotal = DB::table('sales')
->where('tenant_id', $tenantId)
->where('sale_date', $today)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
return [
'cumulative_sales' => (int) $cumulativeSales,
'achievement_rate' => (int) $achievementRate,
'yoy_change' => $yoyChange,
'monthly_sales' => (int) $monthlySales,
'monthly_trend' => $monthlyTrend,
'client_sales' => $clientSales,
'daily_items' => $dailyItems,
'daily_total' => (int) $dailyTotal,
];
}
private function getSalesMonthlyTrend(int $tenantId, int $year): array
{
$monthlyData = DB::table('sales')
->select(DB::raw('MONTH(sale_date) as month'), DB::raw('COALESCE(SUM(total_amount), 0) as amount'))
->where('tenant_id', $tenantId)
->whereYear('sale_date', $year)
->whereNull('deleted_at')
->groupBy(DB::raw('MONTH(sale_date)'))
->orderBy('month')
->get();
$result = [];
for ($i = 1; $i <= 12; $i++) {
$found = $monthlyData->firstWhere('month', $i);
$result[] = [
'month' => sprintf('%d-%02d', $year, $i),
'label' => $i.'월',
'amount' => $found ? (int) $found->amount : 0,
];
}
return $result;
}
private function getSalesClientRanking(int $tenantId, int $year): array
{
$clients = DB::table('sales as s')
->leftJoin('clients as c', 's.client_id', '=', 'c.id')
->select('c.name', DB::raw('SUM(s.total_amount) as amount'))
->where('s.tenant_id', $tenantId)
->whereYear('s.sale_date', $year)
->whereNull('s.deleted_at')
->groupBy('s.client_id', 'c.name')
->orderByDesc('amount')
->limit(5)
->get();
return $clients->map(fn ($item) => [
'name' => $item->name ?? '미지정',
'amount' => (int) $item->amount,
])->toArray();
}
private function getSalesDailyItems(int $tenantId, string $today): array
{
$items = DB::table('sales as s')
->leftJoin('clients as c', 's.client_id', '=', 'c.id')
->select([
's.sale_date as date',
'c.name as client',
's.description as item',
's.total_amount as amount',
's.status',
's.deposit_id',
])
->where('s.tenant_id', $tenantId)
->where('s.sale_date', '>=', Carbon::parse($today)->subDays(30)->format('Y-m-d'))
->whereNull('s.deleted_at')
->orderByDesc('s.sale_date')
->limit(10)
->get();
return $items->map(fn ($item) => [
'date' => $item->date,
'client' => $item->client ?? '미지정',
'item' => $item->item ?? '-',
'amount' => (int) $item->amount,
'status' => $item->deposit_id ? 'deposited' : 'unpaid',
])->toArray();
}
// ─── 2. 매입 현황 ───────────────────────────────
/**
* 매입 현황 요약
*/
public function purchasesSummary(): array
{
$tenantId = $this->tenantId();
$now = Carbon::now();
$year = $now->year;
$month = $now->month;
$today = $now->format('Y-m-d');
// 누적 매입
$cumulativePurchase = DB::table('purchases')
->where('tenant_id', $tenantId)
->whereYear('purchase_date', $year)
->where('purchase_date', '<=', $today)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
// 미결제 금액 (withdrawal_id가 없는 것)
$unpaidAmount = DB::table('purchases')
->where('tenant_id', $tenantId)
->whereYear('purchase_date', $year)
->whereNull('withdrawal_id')
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
// 전년 동월 대비
$thisMonthPurchase = DB::table('purchases')
->where('tenant_id', $tenantId)
->whereYear('purchase_date', $year)
->whereMonth('purchase_date', $month)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
$lastYearMonthPurchase = DB::table('purchases')
->where('tenant_id', $tenantId)
->whereYear('purchase_date', $year - 1)
->whereMonth('purchase_date', $month)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
$yoyChange = $lastYearMonthPurchase > 0
? round((($thisMonthPurchase - $lastYearMonthPurchase) / $lastYearMonthPurchase) * 100, 1)
: 0;
// 월별 추이
$monthlyTrend = $this->getPurchaseMonthlyTrend($tenantId, $year);
// 자재 구성 비율 (purchase_type별)
$materialRatio = $this->getPurchaseMaterialRatio($tenantId, $year);
// 일별 매입 내역
$dailyItems = $this->getPurchaseDailyItems($tenantId, $today);
// 일별 합계
$dailyTotal = DB::table('purchases')
->where('tenant_id', $tenantId)
->where('purchase_date', $today)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
return [
'cumulative_purchase' => (int) $cumulativePurchase,
'unpaid_amount' => (int) $unpaidAmount,
'yoy_change' => $yoyChange,
'monthly_trend' => $monthlyTrend,
'material_ratio' => $materialRatio,
'daily_items' => $dailyItems,
'daily_total' => (int) $dailyTotal,
];
}
private function getPurchaseMonthlyTrend(int $tenantId, int $year): array
{
$monthlyData = DB::table('purchases')
->select(DB::raw('MONTH(purchase_date) as month'), DB::raw('COALESCE(SUM(total_amount), 0) as amount'))
->where('tenant_id', $tenantId)
->whereYear('purchase_date', $year)
->whereNull('deleted_at')
->groupBy(DB::raw('MONTH(purchase_date)'))
->orderBy('month')
->get();
$result = [];
for ($i = 1; $i <= 12; $i++) {
$found = $monthlyData->firstWhere('month', $i);
$result[] = [
'month' => sprintf('%d-%02d', $year, $i),
'label' => $i.'월',
'amount' => $found ? (int) $found->amount : 0,
];
}
return $result;
}
private function getPurchaseMaterialRatio(int $tenantId, int $year): array
{
$colors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
$ratioData = DB::table('purchases')
->select('purchase_type', DB::raw('SUM(total_amount) as value'))
->where('tenant_id', $tenantId)
->whereYear('purchase_date', $year)
->whereNull('deleted_at')
->groupBy('purchase_type')
->orderByDesc('value')
->limit(6)
->get();
$total = $ratioData->sum('value');
$idx = 0;
return $ratioData->map(function ($item) use ($total, $colors, &$idx) {
$name = $this->getPurchaseTypeName($item->purchase_type);
$result = [
'name' => $name,
'value' => (int) $item->value,
'percentage' => $total > 0 ? round(($item->value / $total) * 100, 1) : 0,
'color' => $colors[$idx % count($colors)],
];
$idx++;
return $result;
})->toArray();
}
private function getPurchaseTypeName(?string $type): string
{
$map = [
'원재료매입' => '원자재',
'부재료매입' => '부자재',
'소모품매입' => '소모품',
'외주가공비' => '외주가공',
'접대비' => '접대비',
'복리후생비' => '복리후생',
];
return $map[$type] ?? ($type ?? '기타');
}
private function getPurchaseDailyItems(int $tenantId, string $today): array
{
$items = DB::table('purchases as p')
->leftJoin('clients as c', 'p.client_id', '=', 'c.id')
->select([
'p.purchase_date as date',
'c.name as supplier',
'p.description as item',
'p.total_amount as amount',
'p.withdrawal_id',
])
->where('p.tenant_id', $tenantId)
->where('p.purchase_date', '>=', Carbon::parse($today)->subDays(30)->format('Y-m-d'))
->whereNull('p.deleted_at')
->orderByDesc('p.purchase_date')
->limit(10)
->get();
return $items->map(fn ($item) => [
'date' => $item->date,
'supplier' => $item->supplier ?? '미지정',
'item' => $item->item ?? '-',
'amount' => (int) $item->amount,
'status' => $item->withdrawal_id ? 'paid' : 'unpaid',
])->toArray();
}
// ─── 3. 생산 현황 ───────────────────────────────
/**
* 생산 현황 요약
*/
public function productionSummary(): array
{
$tenantId = $this->tenantId();
$today = Carbon::now();
$todayStr = $today->format('Y-m-d');
$dayOfWeekMap = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
$dayOfWeek = $dayOfWeekMap[$today->dayOfWeek];
// 공정별 작업 현황
$processes = $this->getProductionProcesses($tenantId, $todayStr);
// 출고 현황
$shipment = $this->getShipmentSummary($tenantId, $todayStr);
return [
'date' => $todayStr,
'day_of_week' => $dayOfWeek,
'processes' => $processes,
'shipment' => $shipment,
];
}
private function getProductionProcesses(int $tenantId, string $today): array
{
// 공정별 작업 지시 집계
$processData = DB::table('work_orders as wo')
->leftJoin('processes as p', 'wo.process_id', '=', 'p.id')
->select(
'p.id as process_id',
'p.process_name as process_name',
DB::raw('COUNT(*) as total_work'),
DB::raw("SUM(CASE WHEN wo.status = 'pending' OR wo.status = 'unassigned' OR wo.status = 'waiting' THEN 1 ELSE 0 END) as todo"),
DB::raw("SUM(CASE WHEN wo.status = 'in_progress' THEN 1 ELSE 0 END) as in_progress"),
DB::raw("SUM(CASE WHEN wo.status = 'completed' OR wo.status = 'shipped' THEN 1 ELSE 0 END) as completed"),
DB::raw("SUM(CASE WHEN wo.priority = 'urgent' THEN 1 ELSE 0 END) as urgent"),
)
->where('wo.tenant_id', $tenantId)
->where('wo.scheduled_date', $today)
->where('wo.is_active', true)
->whereNull('wo.deleted_at')
->whereNotNull('wo.process_id')
->groupBy('p.id', 'p.process_name')
->orderBy('p.process_name')
->get();
return $processData->map(function ($process) use ($tenantId, $today) {
$totalWork = (int) $process->total_work;
$todo = (int) $process->todo;
$inProgress = (int) $process->in_progress;
$completed = (int) $process->completed;
// 작업 아이템 (최대 5건)
$workItems = DB::table('work_orders as wo')
->leftJoin('orders as o', 'wo.sales_order_id', '=', 'o.id')
->leftJoin('clients as c', 'o.client_id', '=', 'c.id')
->select([
'wo.id',
'wo.work_order_no as order_no',
'c.name as client',
'wo.project_name as product',
'wo.status',
])
->where('wo.tenant_id', $tenantId)
->where('wo.process_id', $process->process_id)
->where('wo.scheduled_date', $today)
->where('wo.is_active', true)
->whereNull('wo.deleted_at')
->orderByRaw("FIELD(wo.priority, 'urgent', 'normal', 'low')")
->limit(5)
->get();
// 작업자별 현황
$workers = DB::table('work_order_assignees as woa')
->join('work_orders as wo', 'woa.work_order_id', '=', 'wo.id')
->leftJoin('users as u', 'woa.user_id', '=', 'u.id')
->select(
'u.name',
DB::raw('COUNT(*) as assigned'),
DB::raw("SUM(CASE WHEN wo.status IN ('completed', 'shipped') THEN 1 ELSE 0 END) as completed"),
)
->where('wo.tenant_id', $tenantId)
->where('wo.process_id', $process->process_id)
->where('wo.scheduled_date', $today)
->where('wo.is_active', true)
->whereNull('wo.deleted_at')
->groupBy('woa.user_id', 'u.name')
->get();
return [
'process_name' => $process->process_name ?? '미지정',
'total_work' => $totalWork,
'todo' => $todo,
'in_progress' => $inProgress,
'completed' => $completed,
'urgent' => (int) $process->urgent,
'sub_line' => 0,
'regular' => max(0, $totalWork - (int) $process->urgent),
'worker_count' => $workers->count(),
'work_items' => $workItems->map(fn ($wi) => [
'id' => 'wo_'.$wi->id,
'order_no' => $wi->order_no ?? '-',
'client' => $wi->client ?? '미지정',
'product' => $wi->product ?? '-',
'quantity' => 0,
'status' => $this->mapWorkOrderStatus($wi->status),
])->toArray(),
'workers' => $workers->map(fn ($w) => [
'name' => $w->name ?? '미지정',
'assigned' => (int) $w->assigned,
'completed' => (int) $w->completed,
'rate' => $w->assigned > 0 ? round(($w->completed / $w->assigned) * 100, 0) : 0,
])->toArray(),
];
})->toArray();
}
private function mapWorkOrderStatus(string $status): string
{
return match ($status) {
'completed', 'shipped' => 'completed',
'in_progress' => 'in_progress',
default => 'pending',
};
}
private function getShipmentSummary(int $tenantId, string $today): array
{
$thisMonth = Carbon::parse($today);
$monthStart = $thisMonth->copy()->startOfMonth()->format('Y-m-d');
$monthEnd = $thisMonth->copy()->endOfMonth()->format('Y-m-d');
// 예정 출고
$expected = DB::table('shipments')
->where('tenant_id', $tenantId)
->whereBetween('scheduled_date', [$monthStart, $monthEnd])
->whereIn('status', ['scheduled', 'ready'])
->whereNull('deleted_at')
->selectRaw('COUNT(*) as count, COALESCE(SUM(shipping_cost), 0) as amount')
->first();
// 실제 출고
$actual = DB::table('shipments')
->where('tenant_id', $tenantId)
->whereBetween('scheduled_date', [$monthStart, $monthEnd])
->whereIn('status', ['shipping', 'completed'])
->whereNull('deleted_at')
->selectRaw('COUNT(*) as count, COALESCE(SUM(shipping_cost), 0) as amount')
->first();
return [
'expected_amount' => (int) ($expected->amount ?? 0),
'expected_count' => (int) ($expected->count ?? 0),
'actual_amount' => (int) ($actual->amount ?? 0),
'actual_count' => (int) ($actual->count ?? 0),
];
}
// ─── 4. 미출고 내역 ──────────────────────────────
/**
* 미출고 내역 요약
*/
public function unshippedSummary(): array
{
$tenantId = $this->tenantId();
$today = Carbon::now()->format('Y-m-d');
$items = DB::table('shipments as s')
->leftJoin('orders as o', 's.order_id', '=', 'o.id')
->leftJoin('clients as c', 's.client_id', '=', 'c.id')
->select([
's.id',
's.lot_no as port_no',
's.site_name',
'c.name as order_client',
's.scheduled_date as due_date',
])
->where('s.tenant_id', $tenantId)
->whereIn('s.status', ['scheduled', 'ready'])
->whereNull('s.deleted_at')
->orderBy('s.scheduled_date')
->limit(50)
->get();
$result = $items->map(function ($item) use ($today) {
$dueDate = Carbon::parse($item->due_date);
$daysLeft = Carbon::parse($today)->diffInDays($dueDate, false);
return [
'id' => 'us_'.$item->id,
'port_no' => $item->port_no ?? '-',
'site_name' => $item->site_name ?? '-',
'order_client' => $item->order_client ?? '미지정',
'due_date' => $item->due_date,
'days_left' => (int) $daysLeft,
];
})->toArray();
return [
'items' => $result,
'total_count' => count($result),
];
}
// ─── 5. 시공 현황 ───────────────────────────────
/**
* 시공 현황 요약
*/
public function constructionSummary(): array
{
$tenantId = $this->tenantId();
$now = Carbon::now();
$monthStart = $now->copy()->startOfMonth()->format('Y-m-d');
$monthEnd = $now->copy()->endOfMonth()->format('Y-m-d');
// 이번 달 시공 건수
$thisMonthCount = DB::table('contracts')
->where('tenant_id', $tenantId)
->where(function ($q) use ($monthStart, $monthEnd) {
$q->whereBetween('contract_start_date', [$monthStart, $monthEnd])
->orWhereBetween('contract_end_date', [$monthStart, $monthEnd])
->orWhere(function ($q2) use ($monthStart, $monthEnd) {
$q2->where('contract_start_date', '<=', $monthStart)
->where('contract_end_date', '>=', $monthEnd);
});
})
->where('is_active', true)
->whereNull('deleted_at')
->count();
// 완료 건수
$completedCount = DB::table('contracts')
->where('tenant_id', $tenantId)
->where('status', 'completed')
->where(function ($q) use ($monthStart, $monthEnd) {
$q->whereBetween('contract_end_date', [$monthStart, $monthEnd]);
})
->where('is_active', true)
->whereNull('deleted_at')
->count();
// 시공 아이템 목록
$items = DB::table('contracts as ct')
->leftJoin('users as u', 'ct.construction_pm_id', '=', 'u.id')
->select([
'ct.id',
'ct.project_name as site_name',
'ct.partner_name as client',
'ct.contract_start_date as start_date',
'ct.contract_end_date as end_date',
'ct.status',
'ct.stage',
])
->where('ct.tenant_id', $tenantId)
->where('ct.is_active', true)
->whereNull('ct.deleted_at')
->where(function ($q) use ($monthStart, $monthEnd) {
$q->whereBetween('ct.contract_start_date', [$monthStart, $monthEnd])
->orWhereBetween('ct.contract_end_date', [$monthStart, $monthEnd])
->orWhere(function ($q2) use ($monthStart, $monthEnd) {
$q2->where('ct.contract_start_date', '<=', $monthStart)
->where('ct.contract_end_date', '>=', $monthEnd);
});
})
->orderBy('ct.contract_start_date')
->limit(20)
->get();
$today = $now->format('Y-m-d');
return [
'this_month' => $thisMonthCount,
'completed' => $completedCount,
'items' => $items->map(function ($item) use ($today) {
$progress = $this->calculateContractProgress($item, $today);
return [
'id' => 'c_'.$item->id,
'site_name' => $item->site_name ?? '-',
'client' => $item->client ?? '미지정',
'start_date' => $item->start_date,
'end_date' => $item->end_date,
'progress' => $progress,
'status' => $this->mapContractStatus($item->status, $item->start_date, $today),
];
})->toArray(),
];
}
private function calculateContractProgress(object $contract, string $today): int
{
if ($contract->status === 'completed') {
return 100;
}
$start = Carbon::parse($contract->start_date);
$end = Carbon::parse($contract->end_date);
$now = Carbon::parse($today);
if ($now->lt($start)) {
return 0;
}
$totalDays = $start->diffInDays($end);
if ($totalDays <= 0) {
return 0;
}
$elapsedDays = $start->diffInDays($now);
$progress = min(99, round(($elapsedDays / $totalDays) * 100));
return (int) $progress;
}
private function mapContractStatus(string $status, ?string $startDate, string $today): string
{
if ($status === 'completed') {
return 'completed';
}
if ($startDate && Carbon::parse($startDate)->gt(Carbon::parse($today))) {
return 'scheduled';
}
return 'in_progress';
}
// ─── 6. 근태 현황 ───────────────────────────────
/**
* 근태 현황 요약
*/
public function attendanceSummary(): array
{
$tenantId = $this->tenantId();
$today = Carbon::now()->format('Y-m-d');
// 오늘 근태 기록
$attendances = DB::table('attendances as a')
->leftJoin('users as u', 'a.user_id', '=', 'u.id')
->leftJoin('tenant_user_profiles as tup', function ($join) use ($tenantId) {
$join->on('tup.user_id', '=', 'u.id')
->where('tup.tenant_id', '=', $tenantId);
})
->leftJoin('departments as d', 'tup.department_id', '=', 'd.id')
->select([
'a.id',
'a.status',
'u.name',
'd.name as department',
'tup.position_key as position',
])
->where('a.tenant_id', $tenantId)
->where('a.base_date', $today)
->whereNull('a.deleted_at')
->get();
$present = 0;
$onLeave = 0;
$late = 0;
$absent = 0;
$employees = $attendances->map(function ($att) use (&$present, &$onLeave, &$late, &$absent) {
$mappedStatus = $this->mapAttendanceStatus($att->status);
match ($mappedStatus) {
'present' => $present++,
'on_leave' => $onLeave++,
'late' => $late++,
'absent' => $absent++,
default => null,
};
return [
'id' => 'emp_'.$att->id,
'department' => $att->department ?? '-',
'position' => $att->position ?? '-',
'name' => $att->name ?? '-',
'status' => $mappedStatus,
];
})->toArray();
return [
'present' => $present,
'on_leave' => $onLeave,
'late' => $late,
'absent' => $absent,
'employees' => $employees,
];
}
private function mapAttendanceStatus(?string $status): string
{
return match ($status) {
'onTime', 'normal', 'overtime', 'earlyLeave' => 'present',
'late', 'lateEarlyLeave' => 'late',
'vacation', 'halfDayVacation', 'sickLeave' => 'on_leave',
'absent', 'noRecord' => 'absent',
default => 'present',
};
}
}

View File

@@ -7,6 +7,10 @@
use App\Models\Documents\DocumentAttachment;
use App\Models\Documents\DocumentData;
use App\Models\Documents\DocumentTemplate;
use App\Models\Tenants\Approval;
use App\Models\Tenants\ApprovalForm;
use App\Models\Tenants\ApprovalLine;
use App\Models\Tenants\ApprovalStep;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -118,6 +122,7 @@ public function create(array $data): Document
'status' => Document::STATUS_DRAFT,
'linkable_type' => $data['linkable_type'] ?? null,
'linkable_id' => $data['linkable_id'] ?? null,
'rendered_html' => $data['rendered_html'] ?? null,
'created_by' => $userId,
'updated_by' => $userId,
]);
@@ -166,12 +171,16 @@ public function update(int $id, array $data): Document
}
// 기본 정보 수정
$document->fill([
$updateFields = [
'title' => $data['title'] ?? $document->title,
'linkable_type' => $data['linkable_type'] ?? $document->linkable_type,
'linkable_id' => $data['linkable_id'] ?? $document->linkable_id,
'updated_by' => $userId,
]);
];
if (isset($data['rendered_html'])) {
$updateFields['rendered_html'] = $data['rendered_html'];
}
$document->fill($updateFields);
// 반려 상태에서 수정 시 DRAFT로 변경
if ($document->status === Document::STATUS_REJECTED) {
@@ -275,6 +284,9 @@ public function submit(int $id): Document
$document->updated_by = $userId;
$document->save();
// Approval 시스템 브릿지: 결재함(/approval/inbox)에 표시되도록 Approval 자동 생성
$this->createApprovalBridge($document);
return $document->fresh([
'template:id,name,category',
'approvals.user:id,name',
@@ -283,6 +295,78 @@ public function submit(int $id): Document
});
}
/**
* Document → Approval 브릿지 생성
* Document 상신 시 Approval 레코드를 자동 생성하여 /approval/inbox에 표시
*/
private function createApprovalBridge(Document $document): void
{
$form = ApprovalForm::where('code', 'document')
->where('tenant_id', $document->tenant_id)
->first();
if (! $form) {
return; // 문서 결재 양식 미등록 시 스킵 (기존 동작 유지)
}
// 기존 브릿지가 있으면 스킵 (재상신 방지)
$existingApproval = Approval::where('linkable_type', Document::class)
->where('linkable_id', $document->id)
->whereNotIn('status', [Approval::STATUS_CANCELLED])
->first();
if ($existingApproval) {
return;
}
// 문서번호 생성 (Approval 체계)
$today = now()->format('Ymd');
$lastNumber = Approval::where('tenant_id', $document->tenant_id)
->where('document_number', 'like', "AP-{$today}-%")
->orderByDesc('document_number')
->value('document_number');
$seq = 1;
if ($lastNumber && preg_match('/AP-\d{8}-(\d{4})/', $lastNumber, $matches)) {
$seq = (int) $matches[1] + 1;
}
$documentNumber = sprintf('AP-%s-%04d', $today, $seq);
$approval = Approval::create([
'tenant_id' => $document->tenant_id,
'document_number' => $documentNumber,
'form_id' => $form->id,
'title' => $document->title,
'content' => [
'document_id' => $document->id,
'template_id' => $document->template_id,
'document_no' => $document->document_no,
],
'status' => Approval::STATUS_PENDING,
'drafter_id' => $document->created_by,
'drafted_at' => now(),
'current_step' => 1,
'linkable_type' => Document::class,
'linkable_id' => $document->id,
'created_by' => $document->updated_by ?? $document->created_by,
]);
// document_approvals → approval_steps 변환
$docApprovals = $document->approvals()
->orderBy('step')
->get();
foreach ($docApprovals as $docApproval) {
ApprovalStep::create([
'approval_id' => $approval->id,
'step_order' => $docApproval->step,
'step_type' => ApprovalLine::STEP_TYPE_APPROVAL,
'approver_id' => $docApproval->user_id,
'status' => ApprovalStep::STATUS_PENDING,
]);
}
}
/**
* 결재 승인
*/
@@ -579,20 +663,32 @@ public function fqcStatus(int $orderId, int $templateId): array
$tenantId = $this->tenantId();
$order = \App\Models\Orders\Order::where('tenant_id', $tenantId)
->with('items')
->with(['rootNodes.items' => fn ($q) => $q->orderBy('sort_order')])
->findOrFail($orderId);
// 해당 수주의 FQC 문서 조회
// 개소별 대표 OrderItem ID 수집
$representativeItemIds = $order->rootNodes
->map(fn ($node) => $node->items->first()?->id)
->filter()
->values();
// 해당 대표 품목의 FQC 문서 조회
$documents = Document::where('tenant_id', $tenantId)
->where('template_id', $templateId)
->where('linkable_type', \App\Models\Orders\OrderItem::class)
->whereIn('linkable_id', $order->items->pluck('id'))
->whereIn('linkable_id', $representativeItemIds)
->with('data')
->get()
->keyBy('linkable_id');
$items = $order->items->map(function ($orderItem) use ($documents) {
$doc = $documents->get($orderItem->id);
// 개소(root node)별 진행현황
$items = $order->rootNodes->map(function ($node) use ($documents) {
$representativeItem = $node->items->first();
if (! $representativeItem) {
return null;
}
$doc = $documents->get($representativeItem->id);
// 종합판정 값 추출
$judgement = null;
@@ -602,17 +698,17 @@ public function fqcStatus(int $orderId, int $templateId): array
}
return [
'order_item_id' => $orderItem->id,
'floor_code' => $orderItem->floor_code,
'symbol_code' => $orderItem->symbol_code,
'specification' => $orderItem->specification,
'item_name' => $orderItem->item_name,
'order_item_id' => $representativeItem->id,
'floor_code' => $representativeItem->floor_code,
'symbol_code' => $representativeItem->symbol_code,
'specification' => $representativeItem->specification,
'item_name' => $representativeItem->item_name,
'document_id' => $doc?->id,
'document_no' => $doc?->document_no,
'status' => $doc?->status ?? 'NONE',
'judgement' => $judgement,
];
});
})->filter()->values();
// 통계
$total = $items->count();
@@ -634,6 +730,28 @@ public function fqcStatus(int $orderId, int $templateId): array
];
}
// =========================================================================
// Snapshot (Lazy Snapshot)
// =========================================================================
/**
* rendered_html만 업데이트 (상태 무관, canEdit 체크 없음)
* Lazy Snapshot: 조회 시 rendered_html이 없으면 프론트에서 캡처 후 저장
*/
public function patchSnapshot(int $id, string $renderedHtml): Document
{
$tenantId = $this->tenantId();
$document = Document::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
$document->rendered_html = $renderedHtml;
$document->save();
return $document;
}
// =========================================================================
// Resolve/Upsert (React 연동용)
// =========================================================================
@@ -773,24 +891,34 @@ public function upsert(array $data): Document
if ($existingDocument) {
// UPDATE: 기존 update 로직 재사용
return $this->update($existingDocument->id, [
$updatePayload = [
'title' => $data['title'] ?? $existingDocument->title,
'linkable_type' => 'item',
'linkable_id' => $itemId,
'data' => $data['data'] ?? [],
'attachments' => $data['attachments'] ?? [],
]);
];
if (isset($data['rendered_html'])) {
$updatePayload['rendered_html'] = $data['rendered_html'];
}
return $this->update($existingDocument->id, $updatePayload);
}
// CREATE: 기존 create 로직 재사용
return $this->create([
$createPayload = [
'template_id' => $templateId,
'title' => $data['title'] ?? '',
'linkable_type' => 'item',
'linkable_id' => $itemId,
'data' => $data['data'] ?? [],
'attachments' => $data['attachments'] ?? [],
]);
];
if (isset($data['rendered_html'])) {
$createPayload['rendered_html'] = $data['rendered_html'];
}
return $this->create($createPayload);
});
}

View File

@@ -6,29 +6,35 @@
use Illuminate\Support\Facades\DB;
/**
* 접대비 현황 서비스
* 접대비 현황 서비스 (D1.7 리스크 감지형)
*
* CEO 대시보드용 접대비 데이터를 제공합니다.
* CEO 대시보드용 접대비 리스크 데이터를 제공합니다.
* 카드 4개: 주말/심야, 기피업종, 고액결제, 증빙미비
*/
class EntertainmentService extends Service
{
// 접대비 기본 한도율 (중소기업 기준: 매출의 0.3%)
private const DEFAULT_LIMIT_RATE = 0.003;
// 고액 결제 기준 (1회 50만원 초과)
private const HIGH_AMOUNT_THRESHOLD = 500000;
// 기업 규모별 기본 한도 (연간)
private const COMPANY_TYPE_LIMITS = [
'large' => 36000000, // 대기업: 연 3,600만원
'medium' => 36000000, // 중견기업: 연 3,600만원
'small' => 24000000, // 중소기업: 연 2,400만원
// 기피업종 MCC 코드 (유흥, 귀금속, 숙박 등)
private const PROHIBITED_MCC_CODES = [
'5813', // 음주업소
'7011', // 숙박업
'5944', // 귀금속
'7941', // 레저/스포츠
'7992', // 골프장
'7273', // 데이트서비스
'5932', // 골동품
];
// 심야 시간대 (22시 ~ 06시)
private const LATE_NIGHT_START = 22;
private const LATE_NIGHT_END = 6;
/**
* 접대비 현황 요약 조회
* 접대비 리스크 현황 요약 조회 (D1.7)
*
* @param string|null $limitType 기간 타입 (annual|quarterly, 기본: quarterly)
* @param string|null $companyType 기업 유형 (large|medium|small, 기본: medium)
* @param int|null $year 연도 (기본: 현재 연도)
* @param int|null $quarter 분기 (1-4, 기본: 현재 분기)
* @return array{cards: array, check_points: array}
*/
public function getSummary(
@@ -40,73 +46,58 @@ public function getSummary(
$tenantId = $this->tenantId();
$now = Carbon::now();
// 기본값 설정
$year = $year ?? $now->year;
$limitType = $limitType ?? 'quarterly';
$companyType = $companyType ?? 'medium';
$quarter = $quarter ?? $now->quarter;
// 기간 범위 계산
if ($limitType === 'annual') {
$startDate = Carbon::create($year, 1, 1)->format('Y-m-d');
$endDate = Carbon::create($year, 12, 31)->format('Y-m-d');
$periodLabel = "{$year}";
} else {
$startDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d');
$endDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d');
$periodLabel = "{$quarter}사분기";
}
// 연간 시작일 (매출 계산용)
$yearStartDate = Carbon::create($year, 1, 1)->format('Y-m-d');
$yearEndDate = Carbon::create($year, 12, 31)->format('Y-m-d');
// 매출액 조회 (연간)
$annualSales = $this->getAnnualSales($tenantId, $yearStartDate, $yearEndDate);
// 접대비 한도 계산
$annualLimit = $this->calculateLimit($annualSales, $companyType);
$periodLimit = $limitType === 'annual' ? $annualLimit : ($annualLimit / 4);
// 접대비 사용액 조회
$usedAmount = $this->getUsedAmount($tenantId, $startDate, $endDate);
// 잔여 한도
$remainingLimit = max(0, $periodLimit - $usedAmount);
// 리스크 감지 쿼리
$weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $startDate, $endDate);
$prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $startDate, $endDate);
$highAmount = $this->getHighAmountRisk($tenantId, $startDate, $endDate);
$missingReceipt = $this->getMissingReceiptRisk($tenantId, $startDate, $endDate);
// 카드 데이터 구성
$cards = [
[
'id' => 'et_sales',
'label' => '매출',
'amount' => (int) $annualSales,
'id' => 'et_weekend',
'label' => '주말/심야',
'amount' => (int) $weekendLateNight['total'],
'subLabel' => "{$weekendLateNight['count']}",
],
[
'id' => 'et_limit',
'label' => "{{$periodLabel}} 접대비 총 한도",
'amount' => (int) $periodLimit,
'id' => 'et_prohibited',
'label' => '기피업종',
'amount' => (int) $prohibitedBiz['total'],
'subLabel' => $prohibitedBiz['count'] > 0 ? "불인정 {$prohibitedBiz['count']}" : '0건',
],
[
'id' => 'et_remaining',
'label' => "{{$periodLabel}} 접대비 잔여한도",
'amount' => (int) $remainingLimit,
'id' => 'et_high_amount',
'label' => '고액 결제',
'amount' => (int) $highAmount['total'],
'subLabel' => "{$highAmount['count']}",
],
[
'id' => 'et_used',
'label' => "{{$periodLabel}} 접대비 사용금액",
'amount' => (int) $usedAmount,
'id' => 'et_no_receipt',
'label' => '증빙 미비',
'amount' => (int) $missingReceipt['total'],
'subLabel' => "{$missingReceipt['count']}",
],
];
// 체크포인트 생성
$checkPoints = $this->generateCheckPoints(
$periodLabel,
$periodLimit,
$usedAmount,
$remainingLimit,
$tenantId,
$startDate,
$endDate
$checkPoints = $this->generateRiskCheckPoints(
$weekendLateNight,
$prohibitedBiz,
$highAmount,
$missingReceipt
);
return [
@@ -116,65 +107,83 @@ public function getSummary(
}
/**
* 연간 매출액 조회
* 주말/심야 사용 리스크 조회
* expense_date가 주말(토/일) OR barobill join으로 use_time 22~06시
*/
private function getAnnualSales(int $tenantId, string $startDate, string $endDate): float
private function getWeekendLateNightRisk(int $tenantId, string $startDate, string $endDate): array
{
// orders 테이블에서 확정된 수주 합계 조회
$amount = DB::table('orders')
->where('tenant_id', $tenantId)
->where('status_code', 'confirmed')
->whereBetween('received_at', [$startDate, $endDate])
->whereNull('deleted_at')
->sum('total_amount');
return $amount ?: 30530000000; // 임시 기본값 (305억)
}
/**
* 접대비 한도 계산
*/
private function calculateLimit(float $annualSales, string $companyType): float
{
// 기본 한도 (기업 규모별)
$baseLimit = self::COMPANY_TYPE_LIMITS[$companyType] ?? self::COMPANY_TYPE_LIMITS['medium'];
// 매출 기반 한도 (0.3%)
$salesBasedLimit = $annualSales * self::DEFAULT_LIMIT_RATE;
// 기본 한도 + 매출 기반 한도 (실제 세법은 더 복잡하지만 간소화)
return $baseLimit + $salesBasedLimit;
}
/**
* 접대비 사용액 조회
*/
private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float
{
// TODO: 실제 접대비 계정과목에서 조회
// expense_accounts 또는 card_transactions에서 접대비 항목 합계
$amount = DB::table('expense_accounts')
// 주말 사용 (토요일=7, 일요일=1 in MySQL DAYOFWEEK)
$weekendResult = DB::table('expense_accounts')
->where('tenant_id', $tenantId)
->where('account_type', 'entertainment')
->whereBetween('expense_date', [$startDate, $endDate])
->whereNull('deleted_at')
->sum('amount');
->whereRaw('DAYOFWEEK(expense_date) IN (1, 7)')
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
->first();
return $amount ?: 10000000; // 임시 기본값
// 심야 사용 (barobill 카드 거래 내역에서 시간 확인)
$lateNightResult = DB::table('expense_accounts as ea')
->leftJoin('barobill_card_transactions as bct', function ($join) {
$join->on('ea.receipt_no', '=', 'bct.approval_num')
->on('ea.tenant_id', '=', 'bct.tenant_id');
})
->where('ea.tenant_id', $tenantId)
->where('ea.account_type', 'entertainment')
->whereBetween('ea.expense_date', [$startDate, $endDate])
->whereNull('ea.deleted_at')
->whereRaw('DAYOFWEEK(ea.expense_date) NOT IN (1, 7)') // 주말 제외 (중복 방지)
->whereNotNull('bct.use_time')
->where(function ($q) {
$q->whereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) >= ?', [self::LATE_NIGHT_START])
->orWhereRaw('CAST(SUBSTRING(bct.use_time, 1, 2) AS UNSIGNED) < ?', [self::LATE_NIGHT_END]);
})
->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total')
->first();
$totalCount = ($weekendResult->count ?? 0) + ($lateNightResult->count ?? 0);
$totalAmount = ($weekendResult->total ?? 0) + ($lateNightResult->total ?? 0);
return ['count' => $totalCount, 'total' => $totalAmount];
}
/**
* 거래처 누락 건수 조회
* 기피업종 사용 리스크 조회
* barobill의 merchant_biz_type가 MCC 코드 매칭
*/
private function getMissingVendorCount(int $tenantId, string $startDate, string $endDate): array
private function getProhibitedBizTypeRisk(int $tenantId, string $startDate, string $endDate): array
{
$result = DB::table('expense_accounts as ea')
->join('barobill_card_transactions as bct', function ($join) {
$join->on('ea.receipt_no', '=', 'bct.approval_num')
->on('ea.tenant_id', '=', 'bct.tenant_id');
})
->where('ea.tenant_id', $tenantId)
->where('ea.account_type', 'entertainment')
->whereBetween('ea.expense_date', [$startDate, $endDate])
->whereNull('ea.deleted_at')
->whereIn('bct.merchant_biz_type', self::PROHIBITED_MCC_CODES)
->selectRaw('COUNT(*) as count, COALESCE(SUM(ea.amount), 0) as total')
->first();
return [
'count' => $result->count ?? 0,
'total' => $result->total ?? 0,
];
}
/**
* 고액 결제 리스크 조회
* 1회 50만원 초과 결제
*/
private function getHighAmountRisk(int $tenantId, string $startDate, string $endDate): array
{
// TODO: 거래처 정보 누락 건수 조회
$result = DB::table('expense_accounts')
->where('tenant_id', $tenantId)
->where('account_type', 'entertainment')
->whereBetween('expense_date', [$startDate, $endDate])
->whereNull('vendor_id')
->whereNull('deleted_at')
->where('amount', '>', self::HIGH_AMOUNT_THRESHOLD)
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
->first();
@@ -185,72 +194,436 @@ private function getMissingVendorCount(int $tenantId, string $startDate, string
}
/**
* 체크포인트 생성
* 증빙 미비 리스크 조회
* receipt_no가 NULL 또는 빈 값
*/
private function generateCheckPoints(
string $periodLabel,
float $limit,
float $used,
float $remaining,
int $tenantId,
string $startDate,
string $endDate
private function getMissingReceiptRisk(int $tenantId, string $startDate, string $endDate): array
{
$result = DB::table('expense_accounts')
->where('tenant_id', $tenantId)
->where('account_type', 'entertainment')
->whereBetween('expense_date', [$startDate, $endDate])
->whereNull('deleted_at')
->where(function ($q) {
$q->whereNull('receipt_no')
->orWhere('receipt_no', '');
})
->selectRaw('COUNT(*) as count, COALESCE(SUM(amount), 0) as total')
->first();
return [
'count' => $result->count ?? 0,
'total' => $result->total ?? 0,
];
}
/**
* 접대비 상세 정보 조회 (모달용)
*
* @param string|null $companyType 법인 유형 (large|medium|small, 기본: medium)
* @param int|null $year 연도 (기본: 현재 연도)
* @param int|null $quarter 분기 (1-4, 기본: 현재 분기)
*/
public function getDetail(
?string $companyType = 'medium',
?int $year = null,
?int $quarter = null,
?string $startDate = null,
?string $endDate = null
): array {
$tenantId = $this->tenantId();
$now = Carbon::now();
$year = $year ?? $now->year;
$companyType = $companyType ?? 'medium';
$quarter = $quarter ?? $now->quarter;
// 연간 기간 범위 (summary, calculation, quarterly, monthly_usage용 - 항상 연간)
$annualStartDate = Carbon::create($year, 1, 1)->format('Y-m-d');
$annualEndDate = Carbon::create($year, 12, 31)->format('Y-m-d');
// 거래/리스크 필터 기간 (start_date/end_date 전달 시 사용, 없으면 분기 기본)
if ($startDate && $endDate) {
$filterStartDate = $startDate;
$filterEndDate = $endDate;
} else {
$filterStartDate = Carbon::create($year, ($quarter - 1) * 3 + 1, 1)->format('Y-m-d');
$filterEndDate = Carbon::create($year, $quarter * 3, 1)->endOfMonth()->format('Y-m-d');
}
// 기본한도 계산 (중소기업: 3,600만, 일반법인: 1,200만)
$baseLimit = $companyType === 'large' ? 12000000 : 36000000;
// 수입금액 조회 (sales 테이블)
$revenue = $this->getAnnualRevenue($tenantId, $year);
// 수입금액별 추가한도 계산
$revenueAdditional = $this->calculateRevenueAdditionalLimit($revenue);
// 연간 총 한도
$annualLimit = $baseLimit + $revenueAdditional;
$quarterlyLimit = $annualLimit / 4;
// 연간/분기 사용액 조회
$annualUsed = $this->getUsedAmount($tenantId, $annualStartDate, $annualEndDate);
$quarterlyUsed = $this->getUsedAmount($tenantId, $filterStartDate, $filterEndDate);
// 잔여/초과 계산
$annualRemaining = max(0, $annualLimit - $annualUsed);
$annualExceeded = max(0, $annualUsed - $annualLimit);
// 1. 요약 데이터
$summary = [
'annual_limit' => (int) $annualLimit,
'annual_remaining' => (int) $annualRemaining,
'annual_used' => (int) $annualUsed,
'annual_exceeded' => (int) $annualExceeded,
];
// 2. 리스크 검토 카드 (날짜 필터 적용)
$weekendLateNight = $this->getWeekendLateNightRisk($tenantId, $filterStartDate, $filterEndDate);
$prohibitedBiz = $this->getProhibitedBizTypeRisk($tenantId, $filterStartDate, $filterEndDate);
$highAmount = $this->getHighAmountRisk($tenantId, $filterStartDate, $filterEndDate);
$missingReceipt = $this->getMissingReceiptRisk($tenantId, $filterStartDate, $filterEndDate);
$riskReview = [
['label' => '주말/심야', 'amount' => (int) $weekendLateNight['total'], 'count' => $weekendLateNight['count']],
['label' => '기피업종', 'amount' => (int) $prohibitedBiz['total'], 'count' => $prohibitedBiz['count']],
['label' => '고액 결제', 'amount' => (int) $highAmount['total'], 'count' => $highAmount['count']],
['label' => '증빙 미비', 'amount' => (int) $missingReceipt['total'], 'count' => $missingReceipt['count']],
];
// 3. 월별 사용 추이
$monthlyUsage = $this->getMonthlyUsageTrend($tenantId, $year);
// 4. 사용자별 분포 (날짜 필터 적용)
$userDistribution = $this->getUserDistribution($tenantId, $filterStartDate, $filterEndDate);
// 5. 거래 내역 (날짜 필터 적용)
$transactions = $this->getTransactions($tenantId, $filterStartDate, $filterEndDate);
// 6. 손금한도 계산 정보
$calculation = [
'company_type' => $companyType,
'base_limit' => (int) $baseLimit,
'revenue' => (int) $revenue,
'revenue_additional' => (int) $revenueAdditional,
'annual_limit' => (int) $annualLimit,
];
// 7. 분기별 현황
$quarterly = $this->getQuarterlyStatus($tenantId, $year, $quarterlyLimit);
return [
'summary' => $summary,
'risk_review' => $riskReview,
'monthly_usage' => $monthlyUsage,
'user_distribution' => $userDistribution,
'transactions' => $transactions,
'calculation' => $calculation,
'quarterly' => $quarterly,
];
}
/**
* 접대비 사용액 조회
*/
private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float
{
return DB::table('expense_accounts')
->where('tenant_id', $tenantId)
->where('account_type', 'entertainment')
->whereBetween('expense_date', [$startDate, $endDate])
->whereNull('deleted_at')
->sum('amount') ?: 0;
}
/**
* 연간 수입금액(매출) 조회
*/
private function getAnnualRevenue(int $tenantId, int $year): float
{
return DB::table('sales')
->where('tenant_id', $tenantId)
->whereYear('sale_date', $year)
->whereNull('deleted_at')
->sum('total_amount') ?: 0;
}
/**
* 수입금액별 추가한도 계산 (세법 기준)
* 100억 이하: 수입금액 × 0.2%
* 100억 초과 ~ 500억 이하: 2,000만 + (수입금액 - 100억) × 0.1%
* 500억 초과: 6,000만 + (수입금액 - 500억) × 0.03%
*/
private function calculateRevenueAdditionalLimit(float $revenue): float
{
$b10 = 10000000000; // 100억
$b50 = 50000000000; // 500억
if ($revenue <= $b10) {
return $revenue * 0.002;
} elseif ($revenue <= $b50) {
return 20000000 + ($revenue - $b10) * 0.001;
} else {
return 60000000 + ($revenue - $b50) * 0.0003;
}
}
/**
* 월별 사용 추이 조회
*/
private function getMonthlyUsageTrend(int $tenantId, int $year): array
{
$monthlyData = DB::table('expense_accounts')
->select(DB::raw('MONTH(expense_date) as month'), DB::raw('SUM(amount) as amount'))
->where('tenant_id', $tenantId)
->where('account_type', 'entertainment')
->whereYear('expense_date', $year)
->whereNull('deleted_at')
->groupBy(DB::raw('MONTH(expense_date)'))
->orderBy('month')
->get();
$result = [];
for ($i = 1; $i <= 12; $i++) {
$found = $monthlyData->firstWhere('month', $i);
$result[] = [
'month' => $i,
'label' => $i . '월',
'amount' => $found ? (int) $found->amount : 0,
];
}
return $result;
}
/**
* 사용자별 분포 조회
*/
private function getUserDistribution(int $tenantId, string $startDate, string $endDate): array
{
$colors = ['#60A5FA', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#FB923C'];
$distribution = DB::table('expense_accounts as ea')
->leftJoin('users as u', 'ea.created_by', '=', 'u.id')
->select('u.name as user_name', DB::raw('SUM(ea.amount) as amount'))
->where('ea.tenant_id', $tenantId)
->where('ea.account_type', 'entertainment')
->whereBetween('ea.expense_date', [$startDate, $endDate])
->whereNull('ea.deleted_at')
->groupBy('ea.created_by', 'u.name')
->orderByDesc('amount')
->limit(5)
->get();
$total = $distribution->sum('amount');
$result = [];
$idx = 0;
foreach ($distribution as $item) {
$result[] = [
'user_name' => $item->user_name ?? '사용자',
'amount' => (int) $item->amount,
'percentage' => $total > 0 ? round(($item->amount / $total) * 100, 1) : 0,
'color' => $colors[$idx % count($colors)],
];
$idx++;
}
return $result;
}
/**
* 거래 내역 조회
*/
private function getTransactions(int $tenantId, string $startDate, string $endDate): array
{
$transactions = DB::table('expense_accounts as ea')
->leftJoin('users as u', 'ea.created_by', '=', 'u.id')
->leftJoin('barobill_card_transactions as bct', function ($join) {
$join->on('ea.receipt_no', '=', 'bct.approval_num')
->on('ea.tenant_id', '=', 'bct.tenant_id');
})
->select([
'ea.id',
'ea.card_no',
'u.name as user_name',
'ea.expense_date',
'ea.vendor_name',
'ea.amount',
'ea.receipt_no',
'bct.use_time',
'bct.merchant_biz_type',
])
->where('ea.tenant_id', $tenantId)
->where('ea.account_type', 'entertainment')
->whereBetween('ea.expense_date', [$startDate, $endDate])
->whereNull('ea.deleted_at')
->orderByDesc('ea.expense_date')
->limit(100)
->get();
$result = [];
foreach ($transactions as $t) {
$riskType = $this->detectTransactionRiskType($t);
$result[] = [
'id' => $t->id,
'card_name' => $t->card_no ? '카드 *' . substr($t->card_no, -4) : '카드명',
'user_name' => $t->user_name ?? '사용자',
'expense_date' => Carbon::parse($t->expense_date)->format('Y-m-d H:i'),
'vendor_name' => $t->vendor_name ?? '가맹점명',
'amount' => (int) $t->amount,
'risk_type' => $riskType,
];
}
return $result;
}
/**
* 거래 건별 리스크 유형 감지
*/
private function detectTransactionRiskType(object $transaction): string
{
// 기피업종
if ($transaction->merchant_biz_type && in_array($transaction->merchant_biz_type, self::PROHIBITED_MCC_CODES)) {
return '기피업종';
}
// 고액 결제
if ($transaction->amount > self::HIGH_AMOUNT_THRESHOLD) {
return '고액 결제';
}
// 증빙 미비
if (empty($transaction->receipt_no)) {
return '증빙 미비';
}
// 주말/심야 감지
$expenseDate = Carbon::parse($transaction->expense_date);
if ($expenseDate->isWeekend()) {
return '주말/심야';
}
if ($transaction->use_time) {
$hour = (int) substr($transaction->use_time, 0, 2);
if ($hour >= self::LATE_NIGHT_START || $hour < self::LATE_NIGHT_END) {
return '주말/심야';
}
}
return '정상';
}
/**
* 분기별 현황 조회
*/
private function getQuarterlyStatus(int $tenantId, int $year, float $quarterlyLimit): array
{
$result = [];
$previousRemaining = 0;
for ($q = 1; $q <= 4; $q++) {
$startDate = Carbon::create($year, ($q - 1) * 3 + 1, 1)->format('Y-m-d');
$endDate = Carbon::create($year, $q * 3, 1)->endOfMonth()->format('Y-m-d');
$used = $this->getUsedAmount($tenantId, $startDate, $endDate);
$carryover = $previousRemaining > 0 ? $previousRemaining : 0;
$totalLimit = $quarterlyLimit + $carryover;
$remaining = max(0, $totalLimit - $used);
$exceeded = max(0, $used - $totalLimit);
$result[] = [
'quarter' => $q,
'limit' => (int) $quarterlyLimit,
'carryover' => (int) $carryover,
'used' => (int) $used,
'remaining' => (int) $remaining,
'exceeded' => (int) $exceeded,
];
$previousRemaining = $remaining;
}
return $result;
}
/**
* 리스크 감지 체크포인트 생성
*/
private function generateRiskCheckPoints(
array $weekendLateNight,
array $prohibitedBiz,
array $highAmount,
array $missingReceipt
): array {
$checkPoints = [];
$usageRate = $limit > 0 ? ($used / $limit) * 100 : 0;
$usedFormatted = number_format($used / 10000);
$limitFormatted = number_format($limit / 10000);
$remainingFormatted = number_format($remaining / 10000);
$totalRiskCount = $weekendLateNight['count'] + $prohibitedBiz['count']
+ $highAmount['count'] + $missingReceipt['count'];
// 사용률에 따른 체크포인트
if ($usageRate <= 75) {
// 정상 운영
$remainingRate = round(100 - $usageRate);
// 주말/심야
if ($weekendLateNight['count'] > 0) {
$amountFormatted = number_format($weekendLateNight['total'] / 10000);
$checkPoints[] = [
'id' => 'et_cp_normal',
'type' => 'success',
'message' => "{$periodLabel} 접대비 사용 {$usedFormatted}만원 / 한도 {$limitFormatted}만원 ({$remainingRate}%). 여유 있게 운영 중입니다.",
'highlights' => [
['text' => "{$usedFormatted}만원", 'color' => 'green'],
['text' => "{$limitFormatted}만원 ({$remainingRate}%)", 'color' => 'green'],
],
];
} elseif ($usageRate <= 100) {
// 주의 (85% 이상)
$usageRateRounded = round($usageRate);
$checkPoints[] = [
'id' => 'et_cp_warning',
'id' => 'et_cp_weekend',
'type' => 'warning',
'message' => "접대비 한도 {$usageRateRounded}% 도달. 잔여 한도 {$remainingFormatted}만원입니다. 사용 계획을 점검해 주세요.",
'message' => "주말/심야 사용 {$weekendLateNight['count']}건({$amountFormatted}만원) 감지. 업무관련성 소명자료로 증빙해주세요.",
'highlights' => [
['text' => "잔여 한도 {$remainingFormatted}만원", 'color' => 'orange'],
],
];
} else {
// 한도 초과
$overAmount = $used - $limit;
$overFormatted = number_format($overAmount / 10000);
$checkPoints[] = [
'id' => 'et_cp_over',
'type' => 'error',
'message' => "접대비 한도 초과 {$overFormatted}만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.",
'highlights' => [
['text' => "{$overFormatted}만원 발생", 'color' => 'red'],
['text' => "{$weekendLateNight['count']}건({$amountFormatted}만원)", 'color' => 'red'],
],
];
}
// 거래처 정보 누락 체크
$missingVendor = $this->getMissingVendorCount($tenantId, $startDate, $endDate);
if ($missingVendor['count'] > 0) {
$missingTotal = number_format($missingVendor['total'] / 10000);
// 기피업종
if ($prohibitedBiz['count'] > 0) {
$amountFormatted = number_format($prohibitedBiz['total'] / 10000);
$checkPoints[] = [
'id' => 'et_cp_missing',
'id' => 'et_cp_prohibited',
'type' => 'error',
'message' => "접대비 사용 {$missingVendor['count']}건({$missingTotal}만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.",
'message' => "기피업종 사용 {$prohibitedBiz['count']}건({$amountFormatted}만원) 감지. 유흥업종 결제는 접대비 불인정 사유입니다.",
'highlights' => [
['text' => "{$missingVendor['count']}건({$missingTotal}만원)", 'color' => 'red'],
['text' => '거래처 정보가 누락', 'color' => 'red'],
['text' => "{$prohibitedBiz['count']}건({$amountFormatted}만원)", 'color' => 'red'],
['text' => '접대비 불인정', 'color' => 'red'],
],
];
}
// 고액 결제
if ($highAmount['count'] > 0) {
$amountFormatted = number_format($highAmount['total'] / 10000);
$checkPoints[] = [
'id' => 'et_cp_high',
'type' => 'warning',
'message' => "고액 결제 {$highAmount['count']}건({$amountFormatted}만원) 감지. 1회 50만원 초과 결제입니다. 증빙이 필요합니다.",
'highlights' => [
['text' => "{$highAmount['count']}건({$amountFormatted}만원)", 'color' => 'red'],
],
];
}
// 증빙 미비
if ($missingReceipt['count'] > 0) {
$amountFormatted = number_format($missingReceipt['total'] / 10000);
$checkPoints[] = [
'id' => 'et_cp_receipt',
'type' => 'error',
'message' => "미증빙 {$missingReceipt['count']}건({$amountFormatted}만원) 감지. 증빙이 필요합니다.",
'highlights' => [
['text' => "{$missingReceipt['count']}건({$amountFormatted}만원)", 'color' => 'red'],
],
];
}
// 리스크 0건이면 정상 메시지
if ($totalRiskCount === 0) {
$checkPoints[] = [
'id' => 'et_cp_normal',
'type' => 'success',
'message' => '접대비 사용 현황이 정상입니다.',
'highlights' => [
['text' => '정상', 'color' => 'green'],
],
];
}

Some files were not shown because too many files have changed in this diff Show More