From 287e9d6336a10982cb9b2c62c2bf8efdb2c6dadc Mon Sep 17 00:00:00 2001 From: sseoyeon-ssonia Date: Wed, 24 Jun 2026 16:11:26 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EB=B0=8F=20=EC=9E=AC=EA=B3=A0=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 품목 마스터 카드 통계를 전체 기준 API로 분리 - 대시보드 생산량과 출하 대기 카드 집계 기준 수정 - 현재고 모니터링을 활성 품목 마스터 기준으로 조회 --- agent/history/sseoyeon-ssonia/fix-final.md | 32 +++++++++++++++++++ ...ementation-plan-dashboard-inventory-fix.md | 30 +++++++++++++++++ .../com/ssafy/demo_app/api/item/ItemApi.java | 5 +++ .../demo_app/api/item/ItemController.java | 6 ++++ .../api/item/dto/ItemStatsResponse.java | 15 +++++++++ .../service/DashboardServiceImpl.java | 14 ++++---- .../CurrentInventoryRepository.java | 6 ++-- ...InventoryTransactionHistoryRepository.java | 11 +++++++ .../item/repository/ItemMasterRepository.java | 1 + .../domain/item/service/ItemService.java | 3 ++ .../domain/item/service/ItemServiceImpl.java | 10 ++++++ .../OutboundShippingRepository.java | 1 + frontend/src/api/itemMasterApi.ts | 11 +++++++ frontend/src/services/itemMasterService.ts | 10 ++++++ frontend/src/state/itemMasterStore.ts | 18 +++++++++++ frontend/src/views/HomeView.vue | 11 ++++++- frontend/src/views/ItemMasterView.vue | 24 ++++++++++---- 17 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 agent/history/sseoyeon-ssonia/fix-final.md create mode 100644 agent/implementation-plan-dashboard-inventory-fix.md create mode 100644 backend/src/main/java/com/ssafy/demo_app/api/item/dto/ItemStatsResponse.java diff --git a/agent/history/sseoyeon-ssonia/fix-final.md b/agent/history/sseoyeon-ssonia/fix-final.md new file mode 100644 index 0000000..5b7a24e --- /dev/null +++ b/agent/history/sseoyeon-ssonia/fix-final.md @@ -0,0 +1,32 @@ +### 대시보드 및 재고 집계 개선 (Codex) +- **User Intent**: 품목 마스터 카드가 현재 페이지 기준으로 활성/비활성 품목을 세고, 대시보드 생산/출하 카드와 현재고 모니터링 목록이 현업 기준과 다르게 집계되는 문제를 수정 요청 +- **Agent Context**: 품목 통계는 거래처 마스터의 전체 통계 API 패턴을 따라 분리하고, 대시보드 생산량은 최종 생산 입고 이력 기준, 출하 대기는 `READY` 상태 기준, 현재고 목록은 활성 품목 마스터 기준으로 재정의 +- **Key Decisions**: + - 품목 카드는 프론트엔드 페이지 배열 필터링 대신 `GET /api/items/stats`를 사용 — 프론트엔드 4계층 흐름과 거래처 마스터 기존 패턴 준수 + - 최종 생산량은 `PRODUCTION_RECEIPT`와 `PRODUCTION_RECEIPT_CANCEL` 수불 이력의 순합으로 계산 — 마지막 공정에서만 생산 입고 이력이 생성되는 도메인 규칙 반영 + - 출하 대기 카드는 `OutboundShipping.ShippingStatus.READY`만 카운트 — 출하 지시 화면의 `출하 대기 (READY)` 라벨과 동일 기준 적용 + - 현재고 목록은 `item_master` left join 구조를 유지하고 `item_status = 'ACTIVE'` 조건만 추가 — 재고 0개 품목 포함과 비활성 품목 제외 요구를 동시에 만족 +- **Affected Files**:
17개 파일 + + - **Created**: + - `agent/implementation-plan-dashboard-inventory-fix.md` (+30/-0) — 구현 기획서 + - `backend/src/main/java/com/ssafy/demo_app/api/item/dto/ItemStatsResponse.java` (+15/-0) — 품목 전체 통계 응답 DTO + - **Modified**: + - `backend/src/main/java/com/ssafy/demo_app/api/item/ItemApi.java` (+5/-0) — 품목 통계 API 명세 추가 + - `backend/src/main/java/com/ssafy/demo_app/api/item/ItemController.java` (+6/-0) — 품목 통계 컨트롤러 구현 + - `backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java` (+6/-8) — 대시보드 카드 집계 기준 변경 + - `backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/CurrentInventoryRepository.java` (+4/-2) — 활성 품목 기준 현재고 요약 조회 + - `backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java` (+11/-0) — 순 생산 입고 수량 합계 쿼리 추가 + - `backend/src/main/java/com/ssafy/demo_app/domain/item/repository/ItemMasterRepository.java` (+1/-0) — 품목 상태별 카운트 메서드 추가 + - `backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemService.java` (+3/-0) — 품목 통계 서비스 계약 추가 + - `backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemServiceImpl.java` (+10/-0) — 품목 전체/활성/비활성 통계 구현 + - `backend/src/main/java/com/ssafy/demo_app/domain/shipping/repository/OutboundShippingRepository.java` (+1/-0) — 출하 상태별 카운트 메서드 추가 + - `frontend/src/api/itemMasterApi.ts` (+11/-0) — 품목 통계 API 타입 및 호출 추가 + - `frontend/src/services/itemMasterService.ts` (+10/-0) — 품목 통계 서비스 추가 + - `frontend/src/state/itemMasterStore.ts` (+18/-0) — 품목 통계 상태 및 액션 추가 + - `frontend/src/views/HomeView.vue` (+10/-1) — 인사이트 원천을 한국어 지표명으로 표시 + - `frontend/src/views/ItemMasterView.vue` (+18/-6) — 품목 카드 전체 통계 연동 + - **Deleted**: + - 없음 + +
diff --git a/agent/implementation-plan-dashboard-inventory-fix.md b/agent/implementation-plan-dashboard-inventory-fix.md new file mode 100644 index 0000000..50a3055 --- /dev/null +++ b/agent/implementation-plan-dashboard-inventory-fix.md @@ -0,0 +1,30 @@ +- **작업 범위**: + - 품목 마스터 카드의 활성/비활성 품목 수를 현재 페이지 기준이 아닌 전체 품목 기준으로 수정한다. + - 대시보드 카드의 라인 생산량을 공정별 실적 합산이 아닌 최종 생산 입고 수량 기준으로 수정한다. + - 대시보드 카드의 출하 대기를 출하 상태가 `READY`인 지시 건수 기준으로 수정한다. + - 대시보드 우선 확인 인사이트에 사용자가 이해할 수 있는 지표명을 표시한다. + - 현재고 모니터링 목록을 활성 상태 품목 마스터 전체 기준으로 표시하고, 현재고가 0인 품목도 포함한다. + +- **현황 진단**: + - `frontend/src/views/ItemMasterView.vue`는 `itemMasterStore.items`만 필터링해 활성/비활성 카드를 계산하고 있어 현재 페이지 데이터만 반영한다. + - 거래처 마스터는 `GET /api/partners/stats`와 `partnerMasterStore.stats`를 통해 전체 기준 통계를 별도로 조회한다. + - `backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java`는 라인별 생산 실적 행의 `totalQty`를 합산해 여러 공정을 통과한 같은 제품을 중복 계산한다. + - 생산 최종 입고는 마지막 공정 실적 등록 시 `InventoryTransactionHistory`에 `PRODUCTION_RECEIPT`로 기록된다. + - 출하 대기 카드는 기간 조건과 여러 출하 진행 상태를 함께 집계하고 있어, 화면 라벨인 `READY` 상태 출하 지시와 기준이 다르다. + - `CurrentInventoryRepository.findInventorySummaries`는 품목 마스터를 기준으로 left join하므로 0개 품목 포함 구조는 갖춰져 있으나 활성 품목 조건이 없다. + +- **구현 계획**: + - 품목 통계 DTO와 `GET /api/items/stats` 엔드포인트를 추가한다. + - `ItemMasterRepository`에 상태별 count 메서드를 추가하고, `ItemService`에서 전체/활성/비활성 통계를 반환한다. + - 프론트엔드 품목 API, 서비스, 스토어에 통계 조회 흐름을 추가한다. + - 품목 마스터 화면은 최초 진입과 신규 등록 후 목록/통계를 함께 갱신하고, 카드는 `itemMasterStore.stats`를 사용한다. + - `InventoryTransactionHistoryRepository`에 선택 기간의 순 생산 입고 수량 합계 쿼리를 추가한다. + - 생산 입고 취소는 음수 이력으로 저장되므로 `PRODUCTION_RECEIPT`와 `PRODUCTION_RECEIPT_CANCEL`을 합산해 순 최종 생산량을 계산한다. + - `OutboundShippingRepository`에 `READY` 상태 카운트 메서드를 추가하고 대시보드 출하 대기 카드에서 사용한다. + - 대시보드 인사이트의 원천 테이블명은 프론트엔드에서 한국어 지표명으로 변환해 표시한다. + - 현재고 요약 SQL에 `i.item_status = 'ACTIVE'` 조건을 추가해 활성 품목만 조회한다. + +- **검증 계획**: + - 백엔드 `compileJava`로 API/서비스/쿼리 컴파일 정합성을 확인한다. + - 프론트엔드 `npm run build`로 타입과 번들 정합성을 확인한다. + - 변경 후 작업 히스토리 로그를 기록하고 커밋 대상에 포함한다. diff --git a/backend/src/main/java/com/ssafy/demo_app/api/item/ItemApi.java b/backend/src/main/java/com/ssafy/demo_app/api/item/ItemApi.java index 474a94c..c7f4b00 100644 --- a/backend/src/main/java/com/ssafy/demo_app/api/item/ItemApi.java +++ b/backend/src/main/java/com/ssafy/demo_app/api/item/ItemApi.java @@ -4,6 +4,7 @@ import com.ssafy.demo_app.api.item.dto.ItemDuplicateCheckResponse; import com.ssafy.demo_app.api.item.dto.ItemReferenceResponse; import com.ssafy.demo_app.api.item.dto.ItemResponse; +import com.ssafy.demo_app.api.item.dto.ItemStatsResponse; import com.ssafy.demo_app.api.item.dto.ItemStatusUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUsageResponse; @@ -39,6 +40,10 @@ ResponseEntity>> getItems( @Parameter(description = "품목 ID, 품목명, 품목 코드, 규격 검색어") @RequestParam(required = false) String keyword ); + @Operation(summary = "품목 마스터 통계 조회", description = "전체 품목 수와 활성/비활성 품목 수를 전체 데이터 기준으로 조회합니다.") + @GetMapping("/stats") + ResponseEntity> getItemStats(); + @Operation(summary = "중복 품목 검증", description = "품목명, 규격, 단위가 같은 유사 품목을 조회합니다.") @GetMapping("/duplicates") ResponseEntity> checkDuplicates( diff --git a/backend/src/main/java/com/ssafy/demo_app/api/item/ItemController.java b/backend/src/main/java/com/ssafy/demo_app/api/item/ItemController.java index ad188d6..4811715 100644 --- a/backend/src/main/java/com/ssafy/demo_app/api/item/ItemController.java +++ b/backend/src/main/java/com/ssafy/demo_app/api/item/ItemController.java @@ -4,6 +4,7 @@ import com.ssafy.demo_app.api.item.dto.ItemDuplicateCheckResponse; import com.ssafy.demo_app.api.item.dto.ItemReferenceResponse; import com.ssafy.demo_app.api.item.dto.ItemResponse; +import com.ssafy.demo_app.api.item.dto.ItemStatsResponse; import com.ssafy.demo_app.api.item.dto.ItemStatusUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUsageResponse; @@ -33,6 +34,11 @@ public ResponseEntity>> getItems( return ResponseEntity.ok(ApiResponse.success(itemService.getItems(pageable, itemType, itemStatus, keyword))); } + @Override + public ResponseEntity> getItemStats() { + return ResponseEntity.ok(ApiResponse.success(itemService.getItemStats())); + } + @Override public ResponseEntity> checkDuplicates( String itemName, diff --git a/backend/src/main/java/com/ssafy/demo_app/api/item/dto/ItemStatsResponse.java b/backend/src/main/java/com/ssafy/demo_app/api/item/dto/ItemStatsResponse.java new file mode 100644 index 0000000..27c37b5 --- /dev/null +++ b/backend/src/main/java/com/ssafy/demo_app/api/item/dto/ItemStatsResponse.java @@ -0,0 +1,15 @@ +package com.ssafy.demo_app.api.item.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "품목 마스터 전체 통계 응답 객체") +public class ItemStatsResponse { + + private long totalCount; + private long activeCount; + private long inactiveCount; +} diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java b/backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java index 59d78fd..ec73c5b 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java @@ -10,6 +10,7 @@ import com.ssafy.demo_app.domain.production.entity.WorkOrder; import com.ssafy.demo_app.domain.production.repository.ProductionExecutionRepository; import com.ssafy.demo_app.domain.production.repository.WorkOrderRepository; +import com.ssafy.demo_app.domain.shipping.entity.OutboundShipping; import com.ssafy.demo_app.domain.shipping.repository.OutboundShippingRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -54,7 +55,7 @@ public DashboardSummaryResponse getSummary(String period) { DashboardSummaryResponse response = new DashboardSummaryResponse(); response.setGeneratedAt(LocalDateTime.now()); response.setPeriod(normalizedPeriod); - response.setSummaryCards(getSummaryCards(fromDateTime, productionRows, qualityRows)); + response.setSummaryCards(getSummaryCards(fromDateTime, qualityRows)); response.setMetricViews(List.of( metric("production", "생산", "라인별 생산량 비교", "실제 생산 실적의 양품과 불량 수량을 라인 단위로 비교합니다.", chart("BAR", "lineName", List.of("totalQty"), "라인별 생산량", "라인별 수치 비교 데이터이므로 Bar 차트를 추천합니다."), productionRows), @@ -71,15 +72,12 @@ public DashboardSummaryResponse getSummary(String period) { private List getSummaryCards( LocalDateTime fromDateTime, - List> productionRows, List> qualityRows ) { - long productionTotal = productionRows.stream() - .mapToLong(row -> toLong(row.get("totalQty"))) - .sum(); BigDecimal avgDefectRate = average(qualityRows, "defectRate"); long shortageCount = currentInventoryRepository.countItemsUnderSafetyStock(); - long waitingShippingCount = outboundShippingRepository.countWaitingShipping(fromDateTime); + long finalProductionTotal = inventoryTransactionHistoryRepository.sumNetProductionReceiptQty(fromDateTime); + long waitingShippingCount = outboundShippingRepository.countByStatus(OutboundShipping.ShippingStatus.READY); long activeWorkOrderCount = workOrderRepository.countByStatusIn(List.of( WorkOrder.OrderStatus.READY, WorkOrder.OrderStatus.RUN, @@ -87,10 +85,10 @@ private List getSummaryCards( )); return List.of( - card("production-throughput", "라인 생산량", formatNumber(productionTotal) + " EA", "선택 기간 전체 공정 실적", "success"), + card("production-throughput", "라인 생산량", formatNumber(finalProductionTotal) + " EA", "선택 기간 최종 생산 입고", "success"), card("avg-defect-rate", "평균 불량률", avgDefectRate + "%", "제품별 불량률 평균", avgDefectRate.compareTo(BigDecimal.valueOf(3)) >= 0 ? "warning" : "success"), card("stock-shortage", "안전재고 미만", formatNumber(shortageCount) + " 품목", "현재고와 품목 안전재고 비교", shortageCount > 0 ? "danger" : "success"), - card("shipping-backlog", "출하 대기", formatNumber(waitingShippingCount) + " 건", "출하 완료 전 지시 건수", waitingShippingCount > 0 ? "neutral" : "success"), + card("shipping-backlog", "출하 대기", formatNumber(waitingShippingCount) + " 건", "READY 상태 출하 지시", waitingShippingCount > 0 ? "neutral" : "success"), card("active-work-orders", "진행 작업지시", formatNumber(activeWorkOrderCount) + " 건", "READY/RUN/HOLD 상태", "neutral") ); } diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/CurrentInventoryRepository.java b/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/CurrentInventoryRepository.java index c1e2267..97b4227 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/CurrentInventoryRepository.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/CurrentInventoryRepository.java @@ -60,7 +60,8 @@ when count(ci.inventory_id) = 0 then null from item_master i left join current_inventory ci on ci.item_id = i.item_id left join warehouse_location wl on wl.location_id = ci.location_id - where (:keyword is null + where i.item_status = 'ACTIVE' + and (:keyword is null or lower(coalesce(i.item_code, '')) like concat('%', lower(:keyword), '%') or lower(coalesce(i.item_name, '')) like concat('%', lower(:keyword), '%')) group by i.item_id, i.item_code, i.item_name @@ -71,7 +72,8 @@ order by case when i.safety_stock > coalesce(sum(ci.current_qty), 0) then 0 else countQuery = """ select count(*) from item_master i - where (:keyword is null + where i.item_status = 'ACTIVE' + and (:keyword is null or lower(coalesce(i.item_code, '')) like concat('%', lower(:keyword), '%') or lower(coalesce(i.item_name, '')) like concat('%', lower(:keyword), '%')) """, diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java b/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java index cd6aef1..09d3eee 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java @@ -26,6 +26,17 @@ public interface InventoryTransactionHistoryRepository extends JpaRepository findByProductionExecutionOrderByTransactionIdAsc(ProductionExecution productionExecution); List findTop5ByItemOrderByTransactionIdDesc(ItemMaster item); + @Query(""" + select coalesce(sum(ith.quantity), 0) + from InventoryTransactionHistory ith + where ith.createdAt >= :fromDateTime + and ith.transactionType in ( + com.ssafy.demo_app.domain.inventory.entity.TransactionType.PRODUCTION_RECEIPT, + com.ssafy.demo_app.domain.inventory.entity.TransactionType.PRODUCTION_RECEIPT_CANCEL + ) + """) + long sumNetProductionReceiptQty(@Param("fromDateTime") LocalDateTime fromDateTime); + @Query(""" select ith.location.warehouseName, coalesce(sum(ith.quantity), 0) from InventoryTransactionHistory ith diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/item/repository/ItemMasterRepository.java b/backend/src/main/java/com/ssafy/demo_app/domain/item/repository/ItemMasterRepository.java index a76dbd0..513233f 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/item/repository/ItemMasterRepository.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/item/repository/ItemMasterRepository.java @@ -13,6 +13,7 @@ public interface ItemMasterRepository extends JpaRepository JpaSpecificationExecutor { Optional findByItemCode(String itemCode); boolean existsByItemCode(String itemCode); + long countByItemStatus(ItemMaster.ItemStatus itemStatus); List findByItemCodeStartingWith(String itemCodePrefix); List findByItemNameIgnoreCaseAndSpecAndUnit(String itemName, String spec, ItemMaster.Unit unit); List findByItemTypeAndItemStatus(ItemMaster.ItemType itemType, ItemMaster.ItemStatus itemStatus); diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemService.java b/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemService.java index ebc1ea7..37f9b5b 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemService.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemService.java @@ -4,6 +4,7 @@ import com.ssafy.demo_app.api.item.dto.ItemDuplicateCheckResponse; import com.ssafy.demo_app.api.item.dto.ItemReferenceResponse; import com.ssafy.demo_app.api.item.dto.ItemResponse; +import com.ssafy.demo_app.api.item.dto.ItemStatsResponse; import com.ssafy.demo_app.api.item.dto.ItemStatusUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUsageResponse; @@ -22,6 +23,8 @@ PageResponse getItems( ItemResponse getItem(Integer itemId); + ItemStatsResponse getItemStats(); + ItemResponse createItem(ItemRequest request); ItemResponse updateItem(Integer itemId, ItemUpdateRequest request); diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemServiceImpl.java b/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemServiceImpl.java index ec8f849..34b337d 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemServiceImpl.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemServiceImpl.java @@ -6,6 +6,7 @@ import com.ssafy.demo_app.api.item.dto.ItemReferenceResponse; import com.ssafy.demo_app.api.item.dto.ItemRequest; import com.ssafy.demo_app.api.item.dto.ItemResponse; +import com.ssafy.demo_app.api.item.dto.ItemStatsResponse; import com.ssafy.demo_app.api.item.dto.ItemStatusUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUsageResponse; @@ -69,6 +70,15 @@ public ItemResponse getItem(Integer itemId) { return ItemResponse.from(findItem(itemId)); } + @Override + public ItemStatsResponse getItemStats() { + ItemStatsResponse response = new ItemStatsResponse(); + response.setTotalCount(itemMasterRepository.count()); + response.setActiveCount(itemMasterRepository.countByItemStatus(ItemMaster.ItemStatus.ACTIVE)); + response.setInactiveCount(itemMasterRepository.countByItemStatus(ItemMaster.ItemStatus.INACTIVE)); + return response; + } + @Override @Transactional public ItemResponse createItem(ItemRequest request) { diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/shipping/repository/OutboundShippingRepository.java b/backend/src/main/java/com/ssafy/demo_app/domain/shipping/repository/OutboundShippingRepository.java index d6cd8b9..ebeb006 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/shipping/repository/OutboundShippingRepository.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/shipping/repository/OutboundShippingRepository.java @@ -24,6 +24,7 @@ public interface OutboundShippingRepository extends JpaRepository findTopByPartnerOrderByCreatedAtDesc(PartnerMaster partner); List findByPartnerOrderByCreatedAtDescShippingIdDesc(PartnerMaster partner); void deleteByItem(ItemMaster item); + long countByStatus(OutboundShipping.ShippingStatus status); @Query(value = """ select os.shipping_id as shippingId, diff --git a/frontend/src/api/itemMasterApi.ts b/frontend/src/api/itemMasterApi.ts index 02773e2..bae1199 100644 --- a/frontend/src/api/itemMasterApi.ts +++ b/frontend/src/api/itemMasterApi.ts @@ -45,6 +45,12 @@ export interface ItemMasterSearchParams extends PageParams { keyword?: string } +export interface ItemStatsResponse { + totalCount: number + activeCount: number + inactiveCount: number +} + export interface ItemReferenceResponse { itemId: number itemCode: string @@ -101,6 +107,11 @@ export const itemMasterApi = { return response.data }, + async getItemStats() { + const response = await apiClient.get>('/api/items/stats') + return response.data + }, + async getItemReferences(id: number) { const response = await apiClient.get>(`/api/items/${id}/references`) return response.data diff --git a/frontend/src/services/itemMasterService.ts b/frontend/src/services/itemMasterService.ts index 22d4326..136b15c 100644 --- a/frontend/src/services/itemMasterService.ts +++ b/frontend/src/services/itemMasterService.ts @@ -7,6 +7,7 @@ import type { ItemMasterRequest, ItemMasterResponse, ItemMasterSearchParams, + ItemStatsResponse, ItemMasterUpdateRequest, ItemReferenceResponse, ItemStatus, @@ -39,6 +40,15 @@ export const itemMasterService = { } }, + async getItemStats(): Promise { + try { + const response = await itemMasterApi.getItemStats() + return response.data + } catch (error) { + throw new Error(getErrorMessage(error, '품목 통계를 불러오지 못했습니다.')) + } + }, + async getItemReferences(id: number): Promise { try { const response = await itemMasterApi.getItemReferences(id) diff --git a/frontend/src/state/itemMasterStore.ts b/frontend/src/state/itemMasterStore.ts index fe43684..acec0c0 100644 --- a/frontend/src/state/itemMasterStore.ts +++ b/frontend/src/state/itemMasterStore.ts @@ -7,6 +7,7 @@ import type { ItemMasterRequest, ItemMasterResponse, ItemMasterSearchParams, + ItemStatsResponse, ItemMasterUpdateRequest, ItemReferenceResponse, ItemStatus, @@ -20,6 +21,11 @@ export const useItemMasterStore = defineStore('itemMaster', () => { const itemUsages = ref(null) const duplicateCheck = ref(null) const suggestions = ref([]) + const stats = ref({ + totalCount: 0, + activeCount: 0, + inactiveCount: 0 + }) const isLoading = ref(false) const isSaving = ref(false) const error = ref(null) @@ -72,6 +78,16 @@ export const useItemMasterStore = defineStore('itemMaster', () => { } } + async function loadItemStats() { + try { + stats.value = await itemMasterService.getItemStats() + return stats.value + } catch (err) { + error.value = err instanceof Error ? err.message : '품목 통계를 불러오지 못했습니다.' + throw err + } + } + async function loadItemReferences(id: number) { itemReferences.value = await itemMasterService.getItemReferences(id) return itemReferences.value @@ -177,6 +193,7 @@ export const useItemMasterStore = defineStore('itemMaster', () => { itemUsages, duplicateCheck, suggestions, + stats, isLoading, isSaving, error, @@ -188,6 +205,7 @@ export const useItemMasterStore = defineStore('itemMaster', () => { lastSearchParams, loadItems, loadItem, + loadItemStats, loadItemReferences, loadItemUsages, checkDuplicates, diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index ea5ccd3..485a5b0 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -72,6 +72,15 @@ function severityLabel(severity: DashboardSeverity) { }[severity] } +function insightSourceLabel(source: string) { + return { + production_execution: '품질 지표', + current_inventory: '재고 지표', + outbound_shipping: '출하 지표', + dashboard: '대시보드' + }[source] ?? source +} + function selectPeriod(period: DashboardPeriod) { dashboardStore.loadDashboard(period) } @@ -259,7 +268,7 @@ function getDashboardAnalysisQuestion(metricId: DashboardMetricCategory) { - {{ insight.source }} + {{ insightSourceLabel(insight.source) }}
diff --git a/frontend/src/views/ItemMasterView.vue b/frontend/src/views/ItemMasterView.vue index 3e5b016..bc10c80 100644 --- a/frontend/src/views/ItemMasterView.vue +++ b/frontend/src/views/ItemMasterView.vue @@ -50,11 +50,10 @@ const form = reactive({ }) const stats = computed(() => { - const items = itemMasterStore.items return { - total: itemMasterStore.totalElements, - active: items.filter((item) => item.itemStatus === 'ACTIVE').length, - inactive: items.filter((item) => item.itemStatus === 'INACTIVE').length + total: itemMasterStore.stats.totalCount, + active: itemMasterStore.stats.activeCount, + inactive: itemMasterStore.stats.inactiveCount } }) const canWrite = computed(() => authStore.canManageMasterData) @@ -79,10 +78,21 @@ const keywordSuggestions = computed(() => { return [...unique.values()].slice(0, 8) }) -onMounted(() => { - fetchItems(0) +onMounted(async () => { + await Promise.all([ + fetchItems(0), + loadStats() + ]) }) +async function loadStats() { + try { + await itemMasterStore.loadItemStats() + } catch (err) { + pageError.value = err instanceof Error ? err.message : '품목 통계를 불러오지 못했습니다.' + } +} + async function fetchItems(page = itemMasterStore.page) { try { pageError.value = null @@ -206,6 +216,7 @@ async function submitCreate(skipDuplicateCheck = false) { closeCreate() isDuplicateOpen.value = false pendingPayload.value = null + await loadStats() await router.push({ name: 'item-master-detail', params: { id: created.itemId } }) } catch (err) { formError.value = err instanceof Error ? err.message : '품목 등록에 실패했습니다.' @@ -220,6 +231,7 @@ async function submitDuplicateConfirmed() { closeCreate() isDuplicateOpen.value = false pendingPayload.value = null + await loadStats() await router.push({ name: 'item-master-detail', params: { id: created.itemId } }) } catch (err) { formError.value = err instanceof Error ? err.message : '품목 등록에 실패했습니다.' From 1cef8e761a6c50f04c48f2bcba10bfc8d975e3cd Mon Sep 17 00:00:00 2001 From: sseoyeon-ssonia Date: Wed, 24 Jun 2026 16:25:21 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=ED=92=88=EB=AA=A9=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B2=84=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 품목 카드 통계를 기존 목록 API totalElements 기준으로 계산 - 품목 통계 전용 엔드포인트 의존 제거 - 품목 단건 조회 경로를 숫자 ID로 제한 --- agent/history/sseoyeon-ssonia/fix-final.md | 24 +++++++++++++++++++ .../com/ssafy/demo_app/api/item/ItemApi.java | 7 +----- .../demo_app/api/item/ItemController.java | 6 ----- .../api/item/dto/ItemStatsResponse.java | 15 ------------ .../item/repository/ItemMasterRepository.java | 1 - .../domain/item/service/ItemService.java | 3 --- .../domain/item/service/ItemServiceImpl.java | 10 -------- frontend/src/api/itemMasterApi.ts | 5 ---- frontend/src/services/itemMasterService.ts | 12 ++++++++-- 9 files changed, 35 insertions(+), 48 deletions(-) delete mode 100644 backend/src/main/java/com/ssafy/demo_app/api/item/dto/ItemStatsResponse.java diff --git a/agent/history/sseoyeon-ssonia/fix-final.md b/agent/history/sseoyeon-ssonia/fix-final.md index 5b7a24e..aa21e18 100644 --- a/agent/history/sseoyeon-ssonia/fix-final.md +++ b/agent/history/sseoyeon-ssonia/fix-final.md @@ -30,3 +30,27 @@ - 없음 + +### 품목 통계 서버 오류 방지 (Codex) +- **User Intent**: 품목마스터에서만 서버 오류가 발생하고 이전 수정사항이 반영되지 않은 것처럼 보여, 원인 재확인과 수정 요청 +- **Agent Context**: 새로 추가한 `/api/items/stats` 호출이 실행 중인 구버전 백엔드에서 `/api/items/{id}`로 오인되어 문자열 `stats`를 숫자 ID로 변환하다 500 처리될 수 있는 경로를 확인. 별도 신규 엔드포인트 의존을 제거하고 기존 품목 목록 API의 `totalElements`로 전체 기준 통계를 계산하도록 변경. +- **Key Decisions**: + - 품목 통계는 `GET /api/items`를 전체/활성/비활성 조건으로 조회해 계산 — 기존 운영 중인 품목 목록 API만 사용하여 서버 재시작 전후 라우팅 불일치 위험 축소 + - 품목 단건 조회 경로는 숫자 ID만 받도록 제한 — `/stats`, `/duplicates` 같은 고정 경로가 단건 조회로 오인되는 재발 방지 + - 대시보드 최종 생산량, READY 출하 대기, 현재고 활성 품목 조건, 인사이트 지표명 표시가 현재 코드에 남아 있음을 grep으로 재확인 — 사용자 요청 범위의 반영 상태 검증 +- **Affected Files**:
9개 파일 + + - **Created**: + - 없음 + - **Modified**: + - `backend/src/main/java/com/ssafy/demo_app/api/item/ItemApi.java` (+1/-6) — 품목 통계 엔드포인트 제거 및 숫자 ID 경로 제한 + - `backend/src/main/java/com/ssafy/demo_app/api/item/ItemController.java` (+0/-6) — 품목 통계 컨트롤러 제거 + - `backend/src/main/java/com/ssafy/demo_app/domain/item/repository/ItemMasterRepository.java` (+0/-1) — 미사용 상태별 count 메서드 제거 + - `backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemService.java` (+0/-3) — 미사용 품목 통계 서비스 계약 제거 + - `backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemServiceImpl.java` (+0/-10) — 미사용 품목 통계 구현 제거 + - `frontend/src/api/itemMasterApi.ts` (+0/-5) — `/api/items/stats` 호출 제거 + - `frontend/src/services/itemMasterService.ts` (+10/-2) — 기존 목록 API 3회 조회로 전체/활성/비활성 통계 계산 + - **Deleted**: + - `backend/src/main/java/com/ssafy/demo_app/api/item/dto/ItemStatsResponse.java` — 서버 전용 통계 DTO 제거 + +
diff --git a/backend/src/main/java/com/ssafy/demo_app/api/item/ItemApi.java b/backend/src/main/java/com/ssafy/demo_app/api/item/ItemApi.java index c7f4b00..cd7672e 100644 --- a/backend/src/main/java/com/ssafy/demo_app/api/item/ItemApi.java +++ b/backend/src/main/java/com/ssafy/demo_app/api/item/ItemApi.java @@ -4,7 +4,6 @@ import com.ssafy.demo_app.api.item.dto.ItemDuplicateCheckResponse; import com.ssafy.demo_app.api.item.dto.ItemReferenceResponse; import com.ssafy.demo_app.api.item.dto.ItemResponse; -import com.ssafy.demo_app.api.item.dto.ItemStatsResponse; import com.ssafy.demo_app.api.item.dto.ItemStatusUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUsageResponse; @@ -40,10 +39,6 @@ ResponseEntity>> getItems( @Parameter(description = "품목 ID, 품목명, 품목 코드, 규격 검색어") @RequestParam(required = false) String keyword ); - @Operation(summary = "품목 마스터 통계 조회", description = "전체 품목 수와 활성/비활성 품목 수를 전체 데이터 기준으로 조회합니다.") - @GetMapping("/stats") - ResponseEntity> getItemStats(); - @Operation(summary = "중복 품목 검증", description = "품목명, 규격, 단위가 같은 유사 품목을 조회합니다.") @GetMapping("/duplicates") ResponseEntity> checkDuplicates( @@ -53,7 +48,7 @@ ResponseEntity> checkDuplicates( ); @Operation(summary = "품목 단건 조회", description = "ID로 특정 품목 마스터 정보를 조회합니다.") - @GetMapping("/{id}") + @GetMapping("/{id:[0-9]+}") ResponseEntity> getItem( @Parameter(description = "품목 ID", required = true) @PathVariable Integer id ); diff --git a/backend/src/main/java/com/ssafy/demo_app/api/item/ItemController.java b/backend/src/main/java/com/ssafy/demo_app/api/item/ItemController.java index 4811715..ad188d6 100644 --- a/backend/src/main/java/com/ssafy/demo_app/api/item/ItemController.java +++ b/backend/src/main/java/com/ssafy/demo_app/api/item/ItemController.java @@ -4,7 +4,6 @@ import com.ssafy.demo_app.api.item.dto.ItemDuplicateCheckResponse; import com.ssafy.demo_app.api.item.dto.ItemReferenceResponse; import com.ssafy.demo_app.api.item.dto.ItemResponse; -import com.ssafy.demo_app.api.item.dto.ItemStatsResponse; import com.ssafy.demo_app.api.item.dto.ItemStatusUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUsageResponse; @@ -34,11 +33,6 @@ public ResponseEntity>> getItems( return ResponseEntity.ok(ApiResponse.success(itemService.getItems(pageable, itemType, itemStatus, keyword))); } - @Override - public ResponseEntity> getItemStats() { - return ResponseEntity.ok(ApiResponse.success(itemService.getItemStats())); - } - @Override public ResponseEntity> checkDuplicates( String itemName, diff --git a/backend/src/main/java/com/ssafy/demo_app/api/item/dto/ItemStatsResponse.java b/backend/src/main/java/com/ssafy/demo_app/api/item/dto/ItemStatsResponse.java deleted file mode 100644 index 27c37b5..0000000 --- a/backend/src/main/java/com/ssafy/demo_app/api/item/dto/ItemStatsResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.ssafy.demo_app.api.item.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -@Schema(description = "품목 마스터 전체 통계 응답 객체") -public class ItemStatsResponse { - - private long totalCount; - private long activeCount; - private long inactiveCount; -} diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/item/repository/ItemMasterRepository.java b/backend/src/main/java/com/ssafy/demo_app/domain/item/repository/ItemMasterRepository.java index 513233f..a76dbd0 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/item/repository/ItemMasterRepository.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/item/repository/ItemMasterRepository.java @@ -13,7 +13,6 @@ public interface ItemMasterRepository extends JpaRepository JpaSpecificationExecutor { Optional findByItemCode(String itemCode); boolean existsByItemCode(String itemCode); - long countByItemStatus(ItemMaster.ItemStatus itemStatus); List findByItemCodeStartingWith(String itemCodePrefix); List findByItemNameIgnoreCaseAndSpecAndUnit(String itemName, String spec, ItemMaster.Unit unit); List findByItemTypeAndItemStatus(ItemMaster.ItemType itemType, ItemMaster.ItemStatus itemStatus); diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemService.java b/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemService.java index 37f9b5b..ebc1ea7 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemService.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemService.java @@ -4,7 +4,6 @@ import com.ssafy.demo_app.api.item.dto.ItemDuplicateCheckResponse; import com.ssafy.demo_app.api.item.dto.ItemReferenceResponse; import com.ssafy.demo_app.api.item.dto.ItemResponse; -import com.ssafy.demo_app.api.item.dto.ItemStatsResponse; import com.ssafy.demo_app.api.item.dto.ItemStatusUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUsageResponse; @@ -23,8 +22,6 @@ PageResponse getItems( ItemResponse getItem(Integer itemId); - ItemStatsResponse getItemStats(); - ItemResponse createItem(ItemRequest request); ItemResponse updateItem(Integer itemId, ItemUpdateRequest request); diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemServiceImpl.java b/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemServiceImpl.java index 34b337d..ec8f849 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemServiceImpl.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/item/service/ItemServiceImpl.java @@ -6,7 +6,6 @@ import com.ssafy.demo_app.api.item.dto.ItemReferenceResponse; import com.ssafy.demo_app.api.item.dto.ItemRequest; import com.ssafy.demo_app.api.item.dto.ItemResponse; -import com.ssafy.demo_app.api.item.dto.ItemStatsResponse; import com.ssafy.demo_app.api.item.dto.ItemStatusUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUpdateRequest; import com.ssafy.demo_app.api.item.dto.ItemUsageResponse; @@ -70,15 +69,6 @@ public ItemResponse getItem(Integer itemId) { return ItemResponse.from(findItem(itemId)); } - @Override - public ItemStatsResponse getItemStats() { - ItemStatsResponse response = new ItemStatsResponse(); - response.setTotalCount(itemMasterRepository.count()); - response.setActiveCount(itemMasterRepository.countByItemStatus(ItemMaster.ItemStatus.ACTIVE)); - response.setInactiveCount(itemMasterRepository.countByItemStatus(ItemMaster.ItemStatus.INACTIVE)); - return response; - } - @Override @Transactional public ItemResponse createItem(ItemRequest request) { diff --git a/frontend/src/api/itemMasterApi.ts b/frontend/src/api/itemMasterApi.ts index bae1199..38a4025 100644 --- a/frontend/src/api/itemMasterApi.ts +++ b/frontend/src/api/itemMasterApi.ts @@ -107,11 +107,6 @@ export const itemMasterApi = { return response.data }, - async getItemStats() { - const response = await apiClient.get>('/api/items/stats') - return response.data - }, - async getItemReferences(id: number) { const response = await apiClient.get>(`/api/items/${id}/references`) return response.data diff --git a/frontend/src/services/itemMasterService.ts b/frontend/src/services/itemMasterService.ts index 136b15c..f466f52 100644 --- a/frontend/src/services/itemMasterService.ts +++ b/frontend/src/services/itemMasterService.ts @@ -42,8 +42,16 @@ export const itemMasterService = { async getItemStats(): Promise { try { - const response = await itemMasterApi.getItemStats() - return response.data + const [total, active, inactive] = await Promise.all([ + itemMasterApi.getItems({ page: 0, size: 1 }), + itemMasterApi.getItems({ page: 0, size: 1, itemStatus: 'ACTIVE' }), + itemMasterApi.getItems({ page: 0, size: 1, itemStatus: 'INACTIVE' }) + ]) + return { + totalCount: total.data.totalElements, + activeCount: active.data.totalElements, + inactiveCount: inactive.data.totalElements + } } catch (error) { throw new Error(getErrorMessage(error, '품목 통계를 불러오지 못했습니다.')) } From e9d486d9c57303046675ceeb4df0c9b47d4d29b1 Mon Sep 17 00:00:00 2001 From: sseoyeon-ssonia Date: Wed, 24 Jun 2026 16:41:02 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EC=B5=9C=EC=A2=85=20=EC=83=9D=EC=82=B0=EB=9F=89=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마지막 공정 양품 수량만 생산량 카드에 반영 - 중간 공정별 실적 중복 합산 제거 --- agent/history/sseoyeon-ssonia/fix-final.md | 20 +++++++++++++++++++ .../service/DashboardServiceImpl.java | 2 +- ...InventoryTransactionHistoryRepository.java | 11 ---------- .../ProductionExecutionRepository.java | 14 +++++++++++++ 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/agent/history/sseoyeon-ssonia/fix-final.md b/agent/history/sseoyeon-ssonia/fix-final.md index aa21e18..41f3d94 100644 --- a/agent/history/sseoyeon-ssonia/fix-final.md +++ b/agent/history/sseoyeon-ssonia/fix-final.md @@ -31,6 +31,26 @@ +### 대시보드 최종 생산량 카드 수정 (Codex) +- **User Intent**: 대시보드 카드의 `라인 생산량`이 공정/라인별 실적을 각각 더해 최종 생산량 1개가 라인 5개일 때 5개로 보이는 문제만 수정 요청 +- **Agent Context**: 기존 수불 입고 기준보다 마지막 공정 실적의 양품 수량이 “모든 공정을 거친 최종 생산량” 정의에 더 직접적으로 부합한다고 판단. 대시보드 카드 값만 마지막 공정 실적 합계로 변경하고, 라인별 분석 차트는 이번 범위에서 제외. +- **Key Decisions**: + - 최종 생산량은 같은 공장/라인의 최대 `operationSeq` 라우팅에 등록된 `ProductionExecution.goodQty`만 합산 — 중간 공정 실적 중복 카운트 방지 + - 대시보드 카드 서비스만 신규 repository 쿼리를 호출 — 기존 API 응답 구조와 프론트엔드 변경 없이 카드 값만 수정 + - 미사용 수불 기반 생산량 합계 쿼리는 제거 — 카드 기준을 하나로 유지하고 혼동 방지 +- **Affected Files**:
4개 파일 + + - **Created**: + - 없음 + - **Modified**: + - `backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java` (+1/-1) — 생산량 카드 값을 마지막 공정 양품 합계로 변경 + - `backend/src/main/java/com/ssafy/demo_app/domain/production/repository/ProductionExecutionRepository.java` (+14/-0) — 마지막 공정 양품 합계 쿼리 추가 + - `backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java` (+0/-11) — 미사용 생산 입고 합계 쿼리 제거 + - **Deleted**: + - 없음 + +
+ ### 품목 통계 서버 오류 방지 (Codex) - **User Intent**: 품목마스터에서만 서버 오류가 발생하고 이전 수정사항이 반영되지 않은 것처럼 보여, 원인 재확인과 수정 요청 - **Agent Context**: 새로 추가한 `/api/items/stats` 호출이 실행 중인 구버전 백엔드에서 `/api/items/{id}`로 오인되어 문자열 `stats`를 숫자 ID로 변환하다 500 처리될 수 있는 경로를 확인. 별도 신규 엔드포인트 의존을 제거하고 기존 품목 목록 API의 `totalElements`로 전체 기준 통계를 계산하도록 변경. diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java b/backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java index ec73c5b..f6a03f7 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java @@ -76,7 +76,7 @@ private List getSummaryCards( ) { BigDecimal avgDefectRate = average(qualityRows, "defectRate"); long shortageCount = currentInventoryRepository.countItemsUnderSafetyStock(); - long finalProductionTotal = inventoryTransactionHistoryRepository.sumNetProductionReceiptQty(fromDateTime); + long finalProductionTotal = productionExecutionRepository.sumFinalOperationGoodQty(fromDateTime); long waitingShippingCount = outboundShippingRepository.countByStatus(OutboundShipping.ShippingStatus.READY); long activeWorkOrderCount = workOrderRepository.countByStatusIn(List.of( WorkOrder.OrderStatus.READY, diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java b/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java index 09d3eee..cd6aef1 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java @@ -26,17 +26,6 @@ public interface InventoryTransactionHistoryRepository extends JpaRepository findByProductionExecutionOrderByTransactionIdAsc(ProductionExecution productionExecution); List findTop5ByItemOrderByTransactionIdDesc(ItemMaster item); - @Query(""" - select coalesce(sum(ith.quantity), 0) - from InventoryTransactionHistory ith - where ith.createdAt >= :fromDateTime - and ith.transactionType in ( - com.ssafy.demo_app.domain.inventory.entity.TransactionType.PRODUCTION_RECEIPT, - com.ssafy.demo_app.domain.inventory.entity.TransactionType.PRODUCTION_RECEIPT_CANCEL - ) - """) - long sumNetProductionReceiptQty(@Param("fromDateTime") LocalDateTime fromDateTime); - @Query(""" select ith.location.warehouseName, coalesce(sum(ith.quantity), 0) from InventoryTransactionHistory ith diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/production/repository/ProductionExecutionRepository.java b/backend/src/main/java/com/ssafy/demo_app/domain/production/repository/ProductionExecutionRepository.java index d2d8b7e..efe7fd0 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/production/repository/ProductionExecutionRepository.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/production/repository/ProductionExecutionRepository.java @@ -31,6 +31,20 @@ order by coalesce(sum(pe.goodQty), 0) + coalesce(sum(pe.defectQty), 0) desc """) List aggregateProductionByLine(@Param("fromDateTime") LocalDateTime fromDateTime); + @Query(""" + select coalesce(sum(pe.goodQty), 0) + from ProductionExecution pe + join pe.routing r + where pe.createdAt >= :fromDateTime + and r.operationSeq = ( + select max(r2.operationSeq) + from FactoryRouting r2 + where r2.factoryName = r.factoryName + and r2.lineName = r.lineName + ) + """) + long sumFinalOperationGoodQty(@Param("fromDateTime") LocalDateTime fromDateTime); + @Query(""" select i.itemName, coalesce(sum(pe.goodQty), 0), coalesce(sum(pe.defectQty), 0) from ProductionExecution pe From d8a147724aaf9441221f63b71bdbd8804de1b081 Mon Sep 17 00:00:00 2001 From: sseoyeon-ssonia Date: Wed, 24 Jun 2026 18:19:43 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=20=EC=83=9D=EC=82=B0=EB=9F=89=20=EC=A7=91=EA=B3=84=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DashboardServiceImpl — 생산량 카드와 라인별 생산량 집계를 생산 입고 이력 기준으로 변경 - InventoryTransactionHistoryRepository — 생산 입고 및 취소 이력을 반영한 대시보드 집계 쿼리 추가 - ProductionExecutionRepository — 공정 실적 기반 대시보드 생산량 집계 제거 - InventoryTransactionHistoryRepositoryTest (new) — 마지막 공정 생산 입고 기준 집계 회귀 테스트 추가 - 검증 — ./gradlew test --tests com.ssafy.demo_app.domain.inventory.repository.InventoryTransactionHistoryRepositoryTest - 검증 — ./gradlew compileJava --- agent/history/sseoyeon-ssonia/fix-final.md | 71 +++++++ .../service/DashboardServiceImpl.java | 10 +- ...InventoryTransactionHistoryRepository.java | 32 ++++ .../ProductionExecutionRepository.java | 24 --- ...ntoryTransactionHistoryRepositoryTest.java | 180 ++++++++++++++++++ 5 files changed, 288 insertions(+), 29 deletions(-) create mode 100644 backend/src/test/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepositoryTest.java diff --git a/agent/history/sseoyeon-ssonia/fix-final.md b/agent/history/sseoyeon-ssonia/fix-final.md index 41f3d94..44cfaa8 100644 --- a/agent/history/sseoyeon-ssonia/fix-final.md +++ b/agent/history/sseoyeon-ssonia/fix-final.md @@ -74,3 +74,74 @@ - `backend/src/main/java/com/ssafy/demo_app/api/item/dto/ItemStatsResponse.java` — 서버 전용 통계 DTO 제거 + +### AI SQL 스키마 요약 리소스 추가 (Codex) +- **User Intent**: AI가 SQL을 생성할 때 DDL 또는 스키마 정보를 먼저 참조하는 항목이 프로젝트에 반영되어 있는지 확인하고, 없으면 생성해달라고 요청. +- **Agent Context**: 기존 AI SQL 흐름에는 `DatabaseSchemaService`가 `information_schema` 기반 스키마 프롬프트를 생성하여 `SqlAssistant`에 전달하는 런타임 반영이 존재함. 다만 정적 참조용 DDL/schema summary 파일은 없어 `backend/src/main/resources/ai/schema-summary.md`를 추가함. +- **Key Decisions**: + - 정적 참조 파일은 `backend/src/main/resources/ai/`에 배치 — AI 관련 리소스인 `few-shot-examples.yml`과 같은 위치를 사용하여 기존 리소스 구조를 따름. + - 실행 코드는 수정하지 않음 — 이미 `DatabaseSchemaService`, `SchemaPromptBuilder`, `SqlAssistant` 흐름이 스키마 선참조를 수행하므로 중복 로딩 로직을 추가하지 않음. + - schema summary에는 허용 테이블, 관계, 날짜 기준, 비즈니스 의미 규칙을 포함 — `agent/project/backend.md`의 AI 도메인 및 MySQL 기반 SQL 생성 맥락에 맞춰 SQL 생성 오류를 줄이는 정보를 문서화함. +- **Affected Files**:
1개 파일 + + - **Created**: + - `backend/src/main/resources/ai/schema-summary.md` — AI SQL 생성 전 참조 가능한 정적 스키마 요약 리소스 + - **Modified**: + - **Deleted**: + +
+ +### 대시보드 생산 집계 기준 수정 (Codex) +- **User Intent**: 대시보드의 라인 생산량 합계와 다차원 분석 지표 생산 합계가 잘못 표시되고, 라인명이 라인명만 표기되어 공장 구분이 되지 않는 문제 수정 요청. 모든 공정 단계가 각각 생산량으로 합산되는 것이 아니라 작업 마감 기준 실제 생산 수량으로 집계되어야 한다는 기준을 제시함. +- **Agent Context**: 기존 `aggregateProductionByLine`은 모든 생산 실적 공정을 합산하여 한 작업이 여러 공정을 통과하면 생산량이 중복 집계될 수 있었음. 대시보드 생산 집계를 마감된 작업지시의 마지막 공정 양품 수량으로 제한하고, 표시 라벨을 `공장/라인` 형식으로 변경함. +- **Key Decisions**: + - 생산량 집계는 `WorkOrder.Status.CLOSE` 작업지시와 라인별 마지막 `operationSeq` 실적만 대상으로 제한 — 작업 마감 후 최종 공정 수량을 생산량으로 보는 도메인 기준을 반영함. + - `totalQty`는 `goodQty`만 사용 — 불량 수량은 생산 완료 수량에 더하지 않고 별도 지표로 유지하여 대시보드 카드 합계와 차트 합계의 기준을 일치시킴. + - 라인 라벨은 `factoryName/lineName`으로 구성 — 같은 라인명이 여러 공장에 존재할 때 구분 가능하도록 대시보드 표시값을 백엔드 응답 단계에서 통일함. + - repository 테스트를 추가 — 중간 공정 실적과 RUN 작업지시가 대시보드 생산 집계에 섞이지 않는지 회귀 검증함. +- **Affected Files**:
3개 파일 + + - **Created**: + - `backend/src/test/java/com/ssafy/demo_app/domain/production/repository/ProductionExecutionRepositoryTest.java` — 대시보드 생산 집계 기준 회귀 테스트 + - **Modified**: + - `backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java` (+3/-3) — 생산 지표 문구와 `totalQty` 산정 기준 수정 + - `backend/src/main/java/com/ssafy/demo_app/domain/production/repository/ProductionExecutionRepository.java` (+15/-5) — 마감 작업 마지막 공정 기준 집계 및 공장/라인 라벨 적용 + - **Deleted**: + +
+ +### 사이드바 통합 스크롤 적용 (Codex) +- **User Intent**: 메인 화면을 스크롤할 때 왼쪽 사이드바도 함께 내려가도록 하고, 사이드바 자체 스크롤 기능을 제거해달라는 요청. +- **Agent Context**: 기존 레이아웃은 `app-content`에 `overflow-y-auto`, `app-sidebar-nav`에 `max-height`와 `overflow-y-auto`가 적용되어 메인 콘텐츠와 사이드바가 별도 스크롤 컨테이너로 동작했음. 전역 레이아웃 CSS에서 내부 스크롤 설정을 제거하여 브라우저 페이지 스크롤 하나로 통합함. +- **Key Decisions**: + - `DefaultLayout.vue` 구조는 유지 — 프론트엔드 계층 구조를 변경하지 않고 공통 레이아웃 CSS만 수정하여 영향 범위를 최소화함. + - `app-content`의 `overflow-y-auto` 제거 — 메인 콘텐츠가 별도 스크롤 영역을 만들지 않고 문서 흐름에 따라 페이지 전체 높이를 확장하도록 함. + - `app-sidebar-nav`의 `max-height`와 `overflow-y-auto` 제거 — 사이드바 내부 스크롤을 없애고 사이드바 전체가 페이지 스크롤과 함께 이동하도록 함. +- **Affected Files**:
1개 파일 + + - **Created**: + - **Modified**: + - `frontend/src/main.css` (+2/-2) — 메인 콘텐츠와 사이드바 nav의 내부 스크롤 제거 + - **Deleted**: + +
+ +### 대시보드 생산량 집계 수정 (Codex) +- **User Intent**: 오늘 생산량이 주문 수량과 맞지 않고 공정 단계별 실적이 단순 합산되어 11개/1개/1개 주문에서 생산량이 68개로 표시되는 문제 수정 요청 +- **Agent Context**: 대시보드 생산량이 `production_execution` 공정 실적을 기준으로 집계되어 중간 공정 수량까지 생산량에 포함되는 것이 원인으로 진단. 마지막 공정 완료 시 생성되는 `PRODUCTION_RECEIPT` 재고 이력을 생산량 기준으로 변경. +- **Key Decisions**: + - 대시보드 생산량 집계를 `InventoryTransactionHistoryRepository`로 이동 — 생산 완료 시점에 생성되는 생산 입고 이력이 실제 생산량 증가 이벤트이므로 Backend 문서의 Domain Repository 책임에 맞게 집계 소스를 조정 + - `PRODUCTION_RECEIPT_CANCEL` 원거래가 존재하는 입고 이력은 제외 — 삭제/취소된 마지막 공정 실적이 대시보드 생산량에 남지 않도록 실제 재고 이력의 취소 관계를 반영 + - 기존 `ProductionExecutionRepository`의 대시보드용 집계 메서드는 제거 — 공정별 실적 Repository가 생산량 대시보드 집계에 재사용되어 같은 버그가 재발하지 않도록 책임을 분리 +- **Affected Files**:
4개 파일 + + - **Created**: + - `backend/src/test/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepositoryTest.java` (+180/-0) — 생산 입고 이력 기반 대시보드 생산량 집계 회귀 테스트 + - **Modified**: + - `backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java` (+5/-5) — 대시보드 생산량 집계 소스를 생산 입고 이력으로 변경 + - `backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java` (+32/-0) — 생산 입고 이력 라인별/총량 집계 JPQL 추가 + - `backend/src/main/java/com/ssafy/demo_app/domain/production/repository/ProductionExecutionRepository.java` (+0/-24) — 공정 실적 기반 대시보드 생산량 집계 메서드 제거 + - **Deleted**: + - 없음 + +
diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java b/backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java index f6a03f7..d8ed332 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java @@ -57,7 +57,7 @@ public DashboardSummaryResponse getSummary(String period) { response.setPeriod(normalizedPeriod); response.setSummaryCards(getSummaryCards(fromDateTime, qualityRows)); response.setMetricViews(List.of( - metric("production", "생산", "라인별 생산량 비교", "실제 생산 실적의 양품과 불량 수량을 라인 단위로 비교합니다.", + metric("production", "생산", "라인별 생산량 비교", "마감된 작업지시의 마지막 공정 양품 수량을 공장/라인 단위로 비교합니다.", chart("BAR", "lineName", List.of("totalQty"), "라인별 생산량", "라인별 수치 비교 데이터이므로 Bar 차트를 추천합니다."), productionRows), metric("quality", "품질", "제품별 불량률 비교", "양품/불량 실적을 기준으로 불량률이 높은 제품을 식별합니다.", chart("BAR", "itemName", List.of("defectRate"), "제품별 불량률", "제품별 비율 비교 데이터이므로 Bar 차트를 추천합니다."), qualityRows), @@ -76,7 +76,7 @@ private List getSummaryCards( ) { BigDecimal avgDefectRate = average(qualityRows, "defectRate"); long shortageCount = currentInventoryRepository.countItemsUnderSafetyStock(); - long finalProductionTotal = productionExecutionRepository.sumFinalOperationGoodQty(fromDateTime); + long finalProductionTotal = inventoryTransactionHistoryRepository.sumProductionReceiptQty(fromDateTime); long waitingShippingCount = outboundShippingRepository.countByStatus(OutboundShipping.ShippingStatus.READY); long activeWorkOrderCount = workOrderRepository.countByStatusIn(List.of( WorkOrder.OrderStatus.READY, @@ -85,7 +85,7 @@ private List getSummaryCards( )); return List.of( - card("production-throughput", "라인 생산량", formatNumber(finalProductionTotal) + " EA", "선택 기간 최종 생산 입고", "success"), + card("production-throughput", "라인 생산량", formatNumber(finalProductionTotal) + " EA", "선택 기간 마감 생산량", "success"), card("avg-defect-rate", "평균 불량률", avgDefectRate + "%", "제품별 불량률 평균", avgDefectRate.compareTo(BigDecimal.valueOf(3)) >= 0 ? "warning" : "success"), card("stock-shortage", "안전재고 미만", formatNumber(shortageCount) + " 품목", "현재고와 품목 안전재고 비교", shortageCount > 0 ? "danger" : "success"), card("shipping-backlog", "출하 대기", formatNumber(waitingShippingCount) + " 건", "READY 상태 출하 지시", waitingShippingCount > 0 ? "neutral" : "success"), @@ -94,7 +94,7 @@ private List getSummaryCards( } private List> getProductionRows(LocalDateTime fromDateTime) { - return productionExecutionRepository.aggregateProductionByLine(fromDateTime).stream() + return inventoryTransactionHistoryRepository.aggregateProductionReceiptByLine(fromDateTime).stream() .map(row -> { long goodQty = toLong(row[1]); long defectQty = toLong(row[2]); @@ -102,7 +102,7 @@ private List> getProductionRows(LocalDateTime fromDateTime) data.put("lineName", row[0]); data.put("goodQty", goodQty); data.put("defectQty", defectQty); - data.put("totalQty", goodQty + defectQty); + data.put("totalQty", goodQty); return data; }) .toList(); diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java b/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java index cd6aef1..875c06d 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepository.java @@ -26,6 +26,38 @@ public interface InventoryTransactionHistoryRepository extends JpaRepository findByProductionExecutionOrderByTransactionIdAsc(ProductionExecution productionExecution); List findTop5ByItemOrderByTransactionIdDesc(ItemMaster item); + @Query(""" + select concat(concat(r.factoryName, '/'), r.lineName), coalesce(sum(ith.quantity), 0), 0 + from InventoryTransactionHistory ith + join ith.productionExecution pe + join pe.routing r + where ith.createdAt >= :fromDateTime + and ith.transactionType = com.ssafy.demo_app.domain.inventory.entity.TransactionType.PRODUCTION_RECEIPT + and not exists ( + select 1 + from InventoryTransactionHistory cancel + where cancel.originalTransaction = ith + and cancel.transactionType = com.ssafy.demo_app.domain.inventory.entity.TransactionType.PRODUCTION_RECEIPT_CANCEL + ) + group by r.factoryName, r.lineName + order by coalesce(sum(ith.quantity), 0) desc, r.factoryName asc, r.lineName asc + """) + List aggregateProductionReceiptByLine(@Param("fromDateTime") LocalDateTime fromDateTime); + + @Query(""" + select coalesce(sum(ith.quantity), 0) + from InventoryTransactionHistory ith + where ith.createdAt >= :fromDateTime + and ith.transactionType = com.ssafy.demo_app.domain.inventory.entity.TransactionType.PRODUCTION_RECEIPT + and not exists ( + select 1 + from InventoryTransactionHistory cancel + where cancel.originalTransaction = ith + and cancel.transactionType = com.ssafy.demo_app.domain.inventory.entity.TransactionType.PRODUCTION_RECEIPT_CANCEL + ) + """) + long sumProductionReceiptQty(@Param("fromDateTime") LocalDateTime fromDateTime); + @Query(""" select ith.location.warehouseName, coalesce(sum(ith.quantity), 0) from InventoryTransactionHistory ith diff --git a/backend/src/main/java/com/ssafy/demo_app/domain/production/repository/ProductionExecutionRepository.java b/backend/src/main/java/com/ssafy/demo_app/domain/production/repository/ProductionExecutionRepository.java index efe7fd0..7a948b7 100644 --- a/backend/src/main/java/com/ssafy/demo_app/domain/production/repository/ProductionExecutionRepository.java +++ b/backend/src/main/java/com/ssafy/demo_app/domain/production/repository/ProductionExecutionRepository.java @@ -21,30 +21,6 @@ public interface ProductionExecutionRepository extends JpaRepository findTop5ByRoutingOrderByExecutionIdDesc(FactoryRouting routing); Optional findTopByRoutingOrderByCreatedAtDesc(FactoryRouting routing); - @Query(""" - select r.lineName, coalesce(sum(pe.goodQty), 0), coalesce(sum(pe.defectQty), 0) - from ProductionExecution pe - join pe.routing r - where pe.createdAt >= :fromDateTime - group by r.lineName - order by coalesce(sum(pe.goodQty), 0) + coalesce(sum(pe.defectQty), 0) desc - """) - List aggregateProductionByLine(@Param("fromDateTime") LocalDateTime fromDateTime); - - @Query(""" - select coalesce(sum(pe.goodQty), 0) - from ProductionExecution pe - join pe.routing r - where pe.createdAt >= :fromDateTime - and r.operationSeq = ( - select max(r2.operationSeq) - from FactoryRouting r2 - where r2.factoryName = r.factoryName - and r2.lineName = r.lineName - ) - """) - long sumFinalOperationGoodQty(@Param("fromDateTime") LocalDateTime fromDateTime); - @Query(""" select i.itemName, coalesce(sum(pe.goodQty), 0), coalesce(sum(pe.defectQty), 0) from ProductionExecution pe diff --git a/backend/src/test/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepositoryTest.java b/backend/src/test/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepositoryTest.java new file mode 100644 index 0000000..16755ea --- /dev/null +++ b/backend/src/test/java/com/ssafy/demo_app/domain/inventory/repository/InventoryTransactionHistoryRepositoryTest.java @@ -0,0 +1,180 @@ +package com.ssafy.demo_app.domain.inventory.repository; + +import com.ssafy.demo_app.domain.inventory.entity.InventoryTransactionHistory; +import com.ssafy.demo_app.domain.inventory.entity.TransactionType; +import com.ssafy.demo_app.domain.inventory.entity.WarehouseLocation; +import com.ssafy.demo_app.domain.item.entity.ItemMaster; +import com.ssafy.demo_app.domain.production.entity.ProductionExecution; +import com.ssafy.demo_app.domain.production.entity.WorkOrder; +import com.ssafy.demo_app.domain.routing.entity.FactoryRouting; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Transactional +class InventoryTransactionHistoryRepositoryTest { + + @Autowired + private InventoryTransactionHistoryRepository inventoryTransactionHistoryRepository; + + @Autowired + private EntityManager em; + + @Test + @DisplayName("대시보드 생산 집계 - 마지막 공정의 생산 입고 수량만 공장/라인 기준으로 집계한다") + void aggregateProductionReceiptByLine_countsOnlyProductionReceipt() { + FactoryRouting firstRouting = createRouting("집계테스트공장", "A라인", 1, "1공정"); + FactoryRouting lastRouting = createRouting("집계테스트공장", "A라인", 2, "2공정"); + ItemMaster item = createItem("DASH-FG-LINE", "대시보드 완제품"); + WarehouseLocation location = createLocation("DASH-RECEIPT", "생산입고창고"); + WorkOrder workOrder = createWorkOrder("WO-DASH-RECEIPT", item, firstRouting); + + createExecution(workOrder, firstRouting, 10, 0); + ProductionExecution lastExecution = createExecution(workOrder, lastRouting, 8, 1); + InventoryTransactionHistory receipt = createHistory( + item, + location, + TransactionType.PRODUCTION_RECEIPT, + 8, + workOrder, + lastExecution, + null + ); + createHistory(item, location, TransactionType.PRODUCTION_RECEIPT_CANCEL, -4, workOrder, null, receipt); + + em.flush(); + em.clear(); + + List rows = inventoryTransactionHistoryRepository.aggregateProductionReceiptByLine(LocalDateTime.now().minusDays(1)); + long total = inventoryTransactionHistoryRepository.sumProductionReceiptQty(LocalDateTime.now().minusDays(1)); + + assertThat(rows) + .filteredOn(row -> "집계테스트공장/A라인".equals(row[0])) + .isEmpty(); + assertThat(total).isZero(); + } + + @Test + @DisplayName("대시보드 생산 집계 - 취소되지 않은 생산 입고 수량만 전체 생산량에 반영한다") + void sumProductionReceiptQty_countsUncancelledReceipt() { + FactoryRouting firstRouting = createRouting("집계테스트공장", "B라인", 1, "1공정"); + FactoryRouting lastRouting = createRouting("집계테스트공장", "B라인", 2, "2공정"); + ItemMaster item = createItem("DASH-FG-TOTAL", "대시보드 총량 완제품"); + WarehouseLocation location = createLocation("DASH-RECEIPT-TOTAL", "생산입고창고2"); + WorkOrder workOrder = createWorkOrder("WO-DASH-TOTAL", item, firstRouting); + + createExecution(workOrder, firstRouting, 11, 0); + ProductionExecution lastExecution = createExecution(workOrder, lastRouting, 5, 0); + createHistory(item, location, TransactionType.PRODUCTION_RECEIPT, 5, workOrder, lastExecution, null); + + em.flush(); + em.clear(); + + List rows = inventoryTransactionHistoryRepository.aggregateProductionReceiptByLine(LocalDateTime.now().minusDays(1)); + long total = inventoryTransactionHistoryRepository.sumProductionReceiptQty(LocalDateTime.now().minusDays(1)); + + Object[] target = rows.stream() + .filter(row -> "집계테스트공장/B라인".equals(row[0])) + .findFirst() + .orElseThrow(); + assertThat(toLong(target[1])).isEqualTo(5L); + assertThat(toLong(target[2])).isZero(); + assertThat(total).isGreaterThanOrEqualTo(5L); + } + + private ItemMaster createItem(String code, String name) { + ItemMaster item = new ItemMaster(); + item.setItemCode(code); + item.setItemName(name); + item.setItemType(ItemMaster.ItemType.FG); + item.setUnit(ItemMaster.Unit.ea); + item.setSafetyStock(0); + em.persist(item); + return item; + } + + private WarehouseLocation createLocation(String code, String warehouseName) { + WarehouseLocation location = new WarehouseLocation(); + location.setLocationCode(code); + location.setWarehouseName(warehouseName); + location.setRackRow("A"); + location.setRackColumn("1"); + location.setProductionReceiptDefault(false); + em.persist(location); + return location; + } + + private FactoryRouting createRouting(String factoryName, String lineName, Integer operationSeq, String operationName) { + FactoryRouting routing = new FactoryRouting(); + routing.setFactoryName(factoryName); + routing.setLineName(lineName); + routing.setOperationSeq(operationSeq); + routing.setOperationName(operationName); + routing.setRoutingStatus(FactoryRouting.RoutingStatus.ACTIVE); + em.persist(routing); + return routing; + } + + private WorkOrder createWorkOrder(String orderNo, ItemMaster item, FactoryRouting routing) { + WorkOrder workOrder = new WorkOrder(); + workOrder.setOrderNo(orderNo); + workOrder.setItem(item); + workOrder.setRouting(routing); + workOrder.setTargetQty(10); + workOrder.setBomVersion("v1.0"); + workOrder.setPlanDate(LocalDate.now()); + workOrder.setStatus(WorkOrder.OrderStatus.RUN); + em.persist(workOrder); + return workOrder; + } + + private ProductionExecution createExecution(WorkOrder workOrder, FactoryRouting routing, Integer goodQty, Integer defectQty) { + ProductionExecution execution = new ProductionExecution(); + execution.setOrder(workOrder); + execution.setRouting(routing); + execution.setGoodQty(goodQty); + execution.setDefectQty(defectQty); + execution.setManHoursMinutes(30); + em.persist(execution); + return execution; + } + + private InventoryTransactionHistory createHistory( + ItemMaster item, + WarehouseLocation location, + TransactionType transactionType, + Integer quantity, + WorkOrder workOrder, + ProductionExecution productionExecution, + InventoryTransactionHistory originalTransaction + ) { + InventoryTransactionHistory history = new InventoryTransactionHistory(); + history.setItem(item); + history.setLocation(location); + history.setTransactionType(transactionType); + history.setQuantity(quantity); + history.setReasonDesc(transactionType.name()); + history.setWorkOrder(workOrder); + history.setProductionExecution(productionExecution); + history.setOriginalTransaction(originalTransaction); + em.persist(history); + return history; + } + + private long toLong(Object value) { + if (value instanceof Number number) { + return number.longValue(); + } + return 0L; + } +} From 893f6fcee3d11d5b3303d24a7ae5034493f693f9 Mon Sep 17 00:00:00 2001 From: sseoyeon-ssonia Date: Wed, 24 Jun 2026 18:30:34 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[TASK-62]=20docs:=20AI=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=9A=94=EC=95=BD=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ementation-plan-dashboard-inventory-fix.md | 30 ------ agent/implementation-plan-global-sort.md | 22 ----- .../src/main/resources/ai/schema-summary.md | 97 +++++++++++++++++++ frontend/src/main.css | 10 +- 4 files changed, 102 insertions(+), 57 deletions(-) delete mode 100644 agent/implementation-plan-dashboard-inventory-fix.md delete mode 100644 agent/implementation-plan-global-sort.md create mode 100644 backend/src/main/resources/ai/schema-summary.md diff --git a/agent/implementation-plan-dashboard-inventory-fix.md b/agent/implementation-plan-dashboard-inventory-fix.md deleted file mode 100644 index 50a3055..0000000 --- a/agent/implementation-plan-dashboard-inventory-fix.md +++ /dev/null @@ -1,30 +0,0 @@ -- **작업 범위**: - - 품목 마스터 카드의 활성/비활성 품목 수를 현재 페이지 기준이 아닌 전체 품목 기준으로 수정한다. - - 대시보드 카드의 라인 생산량을 공정별 실적 합산이 아닌 최종 생산 입고 수량 기준으로 수정한다. - - 대시보드 카드의 출하 대기를 출하 상태가 `READY`인 지시 건수 기준으로 수정한다. - - 대시보드 우선 확인 인사이트에 사용자가 이해할 수 있는 지표명을 표시한다. - - 현재고 모니터링 목록을 활성 상태 품목 마스터 전체 기준으로 표시하고, 현재고가 0인 품목도 포함한다. - -- **현황 진단**: - - `frontend/src/views/ItemMasterView.vue`는 `itemMasterStore.items`만 필터링해 활성/비활성 카드를 계산하고 있어 현재 페이지 데이터만 반영한다. - - 거래처 마스터는 `GET /api/partners/stats`와 `partnerMasterStore.stats`를 통해 전체 기준 통계를 별도로 조회한다. - - `backend/src/main/java/com/ssafy/demo_app/domain/dashboard/service/DashboardServiceImpl.java`는 라인별 생산 실적 행의 `totalQty`를 합산해 여러 공정을 통과한 같은 제품을 중복 계산한다. - - 생산 최종 입고는 마지막 공정 실적 등록 시 `InventoryTransactionHistory`에 `PRODUCTION_RECEIPT`로 기록된다. - - 출하 대기 카드는 기간 조건과 여러 출하 진행 상태를 함께 집계하고 있어, 화면 라벨인 `READY` 상태 출하 지시와 기준이 다르다. - - `CurrentInventoryRepository.findInventorySummaries`는 품목 마스터를 기준으로 left join하므로 0개 품목 포함 구조는 갖춰져 있으나 활성 품목 조건이 없다. - -- **구현 계획**: - - 품목 통계 DTO와 `GET /api/items/stats` 엔드포인트를 추가한다. - - `ItemMasterRepository`에 상태별 count 메서드를 추가하고, `ItemService`에서 전체/활성/비활성 통계를 반환한다. - - 프론트엔드 품목 API, 서비스, 스토어에 통계 조회 흐름을 추가한다. - - 품목 마스터 화면은 최초 진입과 신규 등록 후 목록/통계를 함께 갱신하고, 카드는 `itemMasterStore.stats`를 사용한다. - - `InventoryTransactionHistoryRepository`에 선택 기간의 순 생산 입고 수량 합계 쿼리를 추가한다. - - 생산 입고 취소는 음수 이력으로 저장되므로 `PRODUCTION_RECEIPT`와 `PRODUCTION_RECEIPT_CANCEL`을 합산해 순 최종 생산량을 계산한다. - - `OutboundShippingRepository`에 `READY` 상태 카운트 메서드를 추가하고 대시보드 출하 대기 카드에서 사용한다. - - 대시보드 인사이트의 원천 테이블명은 프론트엔드에서 한국어 지표명으로 변환해 표시한다. - - 현재고 요약 SQL에 `i.item_status = 'ACTIVE'` 조건을 추가해 활성 품목만 조회한다. - -- **검증 계획**: - - 백엔드 `compileJava`로 API/서비스/쿼리 컴파일 정합성을 확인한다. - - 프론트엔드 `npm run build`로 타입과 번들 정합성을 확인한다. - - 변경 후 작업 히스토리 로그를 기록하고 커밋 대상에 포함한다. diff --git a/agent/implementation-plan-global-sort.md b/agent/implementation-plan-global-sort.md deleted file mode 100644 index 98569fe..0000000 --- a/agent/implementation-plan-global-sort.md +++ /dev/null @@ -1,22 +0,0 @@ -- **문제 요약** - - 목록 정렬이 현재 페이지 내부 배열에서만 적용되고 전체 목록 기준으로 재정렬되지 않는다. - - BOM 버전 배지(`v1.0`, `v1.4`, `v2.0` 등)가 전체 목록 기준으로 자연 정렬되지 않는다. - - 수불 유형 배지(`생산불출`, `입고적재` 등)가 화면 표시 라벨 기준으로 정렬되지 않는다. - -- **원인 진단** - - 프론트엔드가 서버 500 오류 회피를 위해 정렬 파라미터를 고정값으로 전달하고 있다. - - 일부 화면은 현재 페이지 응답 배열만 computed로 정렬한다. - - BOM 그룹과 작업지시는 응답 DTO의 집계/계산 필드가 포함되어 JPA 엔티티 필드 정렬만으로 처리할 수 없다. - - 수불 유형은 서버 enum 값과 화면 한글 라벨의 사전식 순서가 다르다. - -- **구현 방침** - - 프론트엔드는 클릭한 정렬 키와 방향을 `sort={field},{direction}` 형태로 서버에 전달한다. - - 백엔드는 화면 DTO 정렬 키를 안전한 엔티티 경로나 DTO 정렬 로직으로 매핑한다. - - BOM 그룹과 작업지시는 전체 조회 결과를 DTO 기준으로 정렬한 뒤 페이지 단위로 자른다. - - 입고 목록은 DTO 필드를 JPA 엔티티 경로로 변환한 `Pageable`을 사용한다. - - 수불 이력의 `transactionType` 정렬은 화면 한글 라벨 기준 순서로 정렬한다. - -- **검증 계획** - - 프론트엔드 `npm run build`로 타입 검사와 번들 빌드를 검증한다. - - 백엔드 `./gradlew compileJava`로 Java 컴파일을 검증한다. - - 정렬 요청 파라미터가 다시 동적 정렬값으로 전달되는지 코드 기준으로 확인한다. diff --git a/backend/src/main/resources/ai/schema-summary.md b/backend/src/main/resources/ai/schema-summary.md new file mode 100644 index 0000000..030ad69 --- /dev/null +++ b/backend/src/main/resources/ai/schema-summary.md @@ -0,0 +1,97 @@ +# AI SQL Schema Summary + +- **Purpose**: AI SQL generation must use this schema summary as a static reference when reviewing the database shape. +- **Runtime Source**: `DatabaseSchemaService` reads allowed table metadata from `information_schema` and builds the live prompt schema. +- **Allowed Scope**: SQL generation must use only the tables and columns exposed by the runtime schema prompt. +- **SQL Type**: Generated SQL must be `SELECT` only. +- **Dialect**: MySQL 8.0. + +## Runtime Prompt Flow + +- `AiQueryServiceImpl` loads the schema before intent classification and SQL generation. +- `DatabaseSchemaMetadataReader` reads allowed tables from `information_schema`. +- `SchemaRelationshipProvider` supplies explicit join relationships. +- `SchemaPromptBuilder` converts table metadata and relationships into the schema prompt. +- `SqlAssistant` receives `Database Schema`, `Business Rules`, `Classification Result`, and few-shot examples before generating SQL. +- `SqlValidationService`, `SqlSemanticValidationService`, and `SqlReviewService` validate generated SQL before execution. + +## Allowed Tables + +- `item_master`: item master data. +- `partner_master`: partner master data. +- `inbound_receipt`: inbound receipt facts. +- `current_inventory`: current inventory facts. +- `inventory_transaction_history`: inventory transaction facts. +- `warehouse_location`: warehouse and location master data. +- `outbound_shipping`: outbound shipping facts. +- `work_order`: production work order data. +- `production_execution`: production execution facts. +- `factory_routing`: factory, line, and operation routing master data. +- `bom_structure`: bill of materials relationships. + +## Relationships + +- `inbound_receipt.item_id` -> `item_master.item_id` +- `inbound_receipt.partner_id` -> `partner_master.partner_id` +- `inbound_receipt.location_id` -> `warehouse_location.location_id` +- `current_inventory.item_id` -> `item_master.item_id` +- `current_inventory.location_id` -> `warehouse_location.location_id` +- `inventory_transaction_history.item_id` -> `item_master.item_id` +- `inventory_transaction_history.location_id` -> `warehouse_location.location_id` +- `inventory_transaction_history.work_order_id` -> `work_order.order_id` +- `inventory_transaction_history.production_execution_id` -> `production_execution.execution_id` +- `outbound_shipping.item_id` -> `item_master.item_id` +- `outbound_shipping.partner_id` -> `partner_master.partner_id` +- `outbound_shipping.picking_location_id` -> `warehouse_location.location_id` +- `work_order.item_id` -> `item_master.item_id` +- `work_order.routing_id` -> `factory_routing.routing_id` +- `production_execution.order_id` -> `work_order.order_id` +- `production_execution.routing_id` -> `factory_routing.routing_id` +- `bom_structure.parent_item_id` -> `item_master.item_id` +- `bom_structure.child_item_id` -> `item_master.item_id` + +## Preferred Date Columns + +- `inbound_receipt`: `inbound_date` +- `outbound_shipping`: `shipped_at` or `created_at` +- `inventory_transaction_history`: `created_at` +- `production_execution`: `created_at` +- `work_order`: `created_at` +- `ai_query_history`: `created_at` + +## Business Meaning Rules + +- **현재고**: Sum `current_inventory.current_qty` by `item_id` unless warehouse, location, or lot detail is requested. +- **안전재고 부족 수량**: `item_master.safety_stock - SUM(current_inventory.current_qty)`. +- **수불 / 입출고 이력**: Use `inventory_transaction_history`. +- **입고 수량**: Use `inbound_receipt.inbound_qty`. +- **출하 대기 잔량**: `outbound_shipping.request_qty - COALESCE(outbound_shipping.shipped_qty, 0)`. +- **불량률**: `SUM(production_execution.defect_qty) / NULLIF(SUM(production_execution.good_qty + production_execution.defect_qty), 0)`. +- **생산량**: `SUM(production_execution.good_qty + production_execution.defect_qty)` unless good quantity only is requested. +- **BOM 소요량**: `bom_structure.quantity` multiplied by the requested production target quantity. +- **BOM 최신 버전**: Latest active `bom_structure.bom_version` for the matched parent item. + +## Entity Matching Rules + +- Item keywords must be matched against `item_master.item_code`, `item_master.item_name`, and `item_master.item_id`. +- Partner keywords must be matched against `partner_master.partner_code`, `partner_master.partner_name`, and `partner_master.partner_id`. +- BOM production target items must match `bom_structure.parent_item_id`. +- BOM material items must match `bom_structure.child_item_id`. +- Free-form item values must not be assumed to be item codes only. + +## Status Rules + +- **출하 대기**: `outbound_shipping.status IN ('READY', 'PICKING', 'PACKING', 'INSPECTING', 'PARTIALLY_SHIPPED')`. +- **작업지시 진행**: `work_order.status IN ('READY', 'RUN', 'HOLD')`. +- **보류 작업지시**: `work_order.status = 'HOLD'`. +- **완료 작업지시**: `work_order.status = 'CLOSE'`. + +## SQL Generation Guardrails + +- Use explicit joins from the relationship list when possible. +- Do not invent tables, columns, or enum values. +- Add `LIMIT 100` unless the query returns a single aggregate row. +- Use deterministic secondary ordering for ranking queries. +- Use `NULLIF` for rate denominators. +- Return decimal ratios between `0` and `1` for rates unless percent is explicitly requested. +- Include human-readable labels where possible instead of exposing numeric IDs only. diff --git a/frontend/src/main.css b/frontend/src/main.css index e510cc6..2a0f559 100644 --- a/frontend/src/main.css +++ b/frontend/src/main.css @@ -61,11 +61,11 @@ --app-line-height-11: 1rem; --app-font-size-xs: 0.75rem; --app-line-height-xs: 1rem; - --app-font-size-sm: 0.875rem; + --app-font-size-sm: 1rem; --app-line-height-sm: 1.25rem; - --app-font-size-base: 1rem; + --app-font-size-base: 1.125rem; --app-line-height-base: 1.5rem; - --app-font-size-lg: 1.125rem; + --app-font-size-lg: 1.25rem; --app-line-height-lg: 1.75rem; --app-font-size-xl: 1.25rem; --app-line-height-xl: 1.75rem; @@ -196,7 +196,7 @@ } .app-content { - @apply flex-grow overflow-y-auto p-8; + @apply flex-grow p-8; } .app-header { @@ -243,7 +243,7 @@ } .app-sidebar-nav { - @apply max-h-[calc(100vh-8rem)] flex-grow space-y-6 overflow-y-auto p-4; + @apply flex-grow space-y-6 p-4; } .app-sidebar-group {