Skip to content

Commit 86e26f9

Browse files
committed
feat(inventory): on-hand inventory search 구현
1 parent d793130 commit 86e26f9

6 files changed

Lines changed: 195 additions & 26 deletions

File tree

docs/uc/04-inventory/UC-INV-002-list-onhand-advanced.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
- `page` (int, default 0)
2323
- `size` (int, default 20, max 200)
2424
- Sorting (whitelist; default: `updatedAt,desc`)
25-
- `sort=warehouseCode|partCode|partName|onHandQty|supplierName|updatedAt[,asc|desc]`
25+
- `sort=warehouseCode|partCode|partName|onHandQty|supplierName|updatedAt|price|priceTotal[,asc|desc]`
2626
- Unified Search
2727
- `q` (string, optional): case-insensitive contains over `part.code | part.name | supplierName | warehouseCode`
2828
- Normalization: trim, collapse spaces, lowercase matching
@@ -39,7 +39,7 @@
3939
- 200 OK → `ApiResponse.success(PageEnvelope<OnHandSummaryDto>)`
4040
- `OnHandSummaryDto`
4141
- `warehouseCode: string`, `partId: long`, `partCode: string`, `partName: string`, `onHandQty: int`, `supplierName?: string`,
42-
`updatedAt: string(KST)`, `safetyStockQty?: int`, `belowSafety?: boolean`
42+
`updatedAt: string(KST)`, `safetyStockQty?: int`, `lowStock?: boolean`, `price: int`, `priceTotal: int(price*onHandQty)`
4343

4444
## Examples
4545
```

src/main/java/com/gearfirst/warehouse/api/inventory/InventoryController.java

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,11 @@ public class InventoryController {
3030

3131
private final InventoryService service;
3232

33-
@Operation(summary = "재고 현황(On-hand) 목록", description = "창고/부품 키워드로 On-hand 목록을 조회합니다. 필터는 AND로 결합됩니다. partKeyword/supplierName은 대소문자 무시 contains. 수량 범위는 minQty ≤ onHandQty ≤ maxQty. 페이지/사이즈 기본값: page=0, size=20. 정렬 허용 필드: partName, partCode, onHandQty, lastUpdatedAt. 잘못된 정렬 키 또는 잘못된 범위는 400.")
33+
@Operation(summary = "OLD - 재고 현황(On-hand) 목록", description = "창고/부품 키워드로 On-hand 목록을 조회합니다. 필터는 AND로 결합됩니다. partKeyword/supplierName은 대소문자 무시 contains. 수량 범위는 minQty ≤ onHandQty ≤ maxQty. 페이지/사이즈 기본값: page=0, size=20. 정렬 허용 필드: partName, partCode, onHandQty, lastUpdatedAt. 잘못된 정렬 키 또는 잘못된 범위는 400.")
3434
@Parameters({
3535
@Parameter(name = "warehouseCode", description = "창고 코드(예: 서울)"),
3636
@Parameter(name = "partKeyword", description = "부품 코드/이름 키워드 (대소문자 무시 contains)"),
3737
@Parameter(name = "supplierName", description = "공급업체 이름 (대소문자 무시 contains)"),
38-
@Parameter(name = "minQty", description = "최소 수량"),
39-
@Parameter(name = "maxQty", description = "최대 수량"),
4038
@Parameter(name = "page", description = "페이지(기본 0, 최소 0)"),
4139
@Parameter(name = "size", description = "페이지 크기(기본 20, 1..100)"),
4240
@Parameter(name = "sort", description = "정렬 필드(허용: partName,partCode,onHandQty,lastUpdatedAt)")
@@ -70,7 +68,7 @@ public ResponseEntity<CommonApiResponse<PageEnvelope<OnHandSummary>>> listOnHand
7068
}
7169
// Validate sort keys when provided (whitelist)
7270
if (sort != null && !sort.isEmpty()) {
73-
Set<String> allowed = Set.of("partName", "partCode", "onHandQty", "lastUpdatedAt");
71+
Set<String> allowed = Set.of("partName", "partCode", "onHandQty", "lastUpdatedAt", "updatedAt");
7472
for (String srt : sort) {
7573
String key = srt == null ? null : srt.split(",")[0];
7674
if (key == null || !allowed.contains(key)) {
@@ -83,4 +81,55 @@ public ResponseEntity<CommonApiResponse<PageEnvelope<OnHandSummary>>> listOnHand
8381
warehouseCode, partKeyword, supplierName, minQty, maxQty, p, s, sort);
8482
return CommonApiResponse.success(SuccessStatus.SEND_INVENTORY_ONHAND_LIST_SUCCESS, envelope);
8583
}
84+
85+
@Operation(summary = "재고 현황(On-hand) 고급 검색", description = "UC-INV-002. q/partId/partCode/partName/warehouseCode/supplierName/minQty/maxQty로 AND 결합 필터. 정렬 화이트리스트: warehouseCode,partCode,partName,onHandQty,supplierName,updatedAt. 기본 정렬: updatedAt,desc. 페이지 사이즈 최대 200.")
86+
@Parameters({
87+
@Parameter(name = "q", description = "통합 검색 (partCode|partName|supplierName|warehouseCode) contains, case-insensitive"),
88+
@Parameter(name = "partId", description = "부품 ID exact"),
89+
@Parameter(name = "partCode", description = "부품 코드 contains, case-insensitive"),
90+
@Parameter(name = "partName", description = "부품 이름 contains, case-insensitive"),
91+
@Parameter(name = "warehouseCode", description = "창고 코드 exact"),
92+
@Parameter(name = "supplierName", description = "공급업체 이름 contains, case-insensitive"),
93+
@Parameter(name = "minQty", description = "최소 수량"),
94+
@Parameter(name = "maxQty", description = "최대 수량"),
95+
@Parameter(name = "page", description = "페이지(0..)", example = "0"),
96+
@Parameter(name = "size", description = "페이지 크기(1..200)", example = "20"),
97+
@Parameter(name = "sort", description = "정렬(허용: warehouseCode,partCode,partName,onHandQty,supplierName,updatedAt)")
98+
})
99+
@GetMapping("/on-hand")
100+
public ResponseEntity<CommonApiResponse<PageEnvelope<OnHandSummary>>> listOnHandAdvanced(
101+
@RequestParam(required = false) String q,
102+
@RequestParam(required = false) Long partId,
103+
@RequestParam(required = false) String partCode,
104+
@RequestParam(required = false) String partName,
105+
@RequestParam(required = false) String warehouseCode,
106+
@RequestParam(required = false) String supplierName,
107+
@RequestParam(required = false) Integer minQty,
108+
@RequestParam(required = false) Integer maxQty,
109+
@RequestParam(required = false, defaultValue = "0") Integer page,
110+
@RequestParam(required = false, defaultValue = "20") Integer size,
111+
@RequestParam(required = false) List<String> sort
112+
) {
113+
int p = page == null ? 0 : page;
114+
int s = size == null ? 20 : size;
115+
if (p < 0 || s < 1 || s > 200) {
116+
throw new BadRequestException(ErrorStatus.VALIDATION_REQUEST_MISSING_EXCEPTION);
117+
}
118+
if (minQty != null && maxQty != null && minQty > maxQty) {
119+
throw new BadRequestException(ErrorStatus.VALIDATION_REQUEST_MISSING_EXCEPTION);
120+
}
121+
if (sort != null && !sort.isEmpty()) {
122+
Set<String> allowed = Set.of("warehouseCode", "partCode", "partName", "onHandQty", "supplierName",
123+
"updatedAt");
124+
for (String srt : sort) {
125+
String key = srt == null ? null : srt.split(",")[0];
126+
if (key == null || !allowed.contains(key)) {
127+
throw new BadRequestException(ErrorStatus.VALIDATION_REQUEST_MISSING_EXCEPTION);
128+
}
129+
}
130+
}
131+
PageEnvelope<OnHandSummary> envelope = service.listOnHandAdvanced(
132+
q, partId, partCode, partName, warehouseCode, supplierName, minQty, maxQty, p, s, sort);
133+
return CommonApiResponse.success(SuccessStatus.SEND_INVENTORY_ONHAND_LIST_SUCCESS, envelope);
134+
}
86135
}

src/main/java/com/gearfirst/warehouse/api/inventory/dto/OnHandDtos.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,21 @@ public record OnHandSummary(
88
String warehouseCode,
99
PartRef part,
1010
int onHandQty,
11-
String lastUpdatedAt,
11+
String updatedAt,
1212
boolean lowStock,
13-
Integer safetyStockQty
13+
Integer safetyStockQty,
14+
String supplierName,
15+
Integer price,
16+
Integer priceTotal
1417
) {
18+
// Backward-compatible constructor used by existing tests (without supplierName/price fields)
19+
public OnHandSummary(String warehouseCode,
20+
PartRef part,
21+
int onHandQty,
22+
String updatedAt,
23+
boolean lowStock,
24+
Integer safetyStockQty) {
25+
this(warehouseCode, part, onHandQty, updatedAt, lowStock, safetyStockQty, null, null, null);
26+
}
1527
}
1628
}

src/main/java/com/gearfirst/warehouse/api/inventory/service/InventoryService.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
public interface InventoryService {
88

9-
/** List API with extended filters used by controllers and services. */
9+
/** Legacy list API kept for backward compatibility (/inventory/onhand). */
1010
PageEnvelope<OnHandSummary> listOnHand(
1111
String warehouseCode,
1212
String partKeyword,
@@ -18,6 +18,27 @@ PageEnvelope<OnHandSummary> listOnHand(
1818
List<String> sort
1919
);
2020

21+
/** Advanced list API for UC-INV-002 (/inventory/on-hand). */
22+
default PageEnvelope<OnHandSummary> listOnHandAdvanced(
23+
String q,
24+
Long partId,
25+
String partCode,
26+
String partName,
27+
String warehouseCode,
28+
String supplierName,
29+
Integer minQty,
30+
Integer maxQty,
31+
int page,
32+
int size,
33+
List<String> sort
34+
) {
35+
// Default fallback maps to legacy API with a best-effort keyword
36+
String keyword = (q != null && !q.isBlank()) ? q :
37+
((partCode != null && !partCode.isBlank()) ? partCode :
38+
((partName != null && !partName.isBlank()) ? partName : null));
39+
return listOnHand(warehouseCode, keyword, supplierName, minQty, maxQty, page, size, sort);
40+
}
41+
2142
/** Increase on-hand by qty for the given warehouse/part (warehouseId is optional for MVP). */
2243
void increase(String warehouseCode, Long partId, int qty);
2344

src/main/java/com/gearfirst/warehouse/api/inventory/service/InventoryServiceImpl.java

Lines changed: 97 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,35 +62,29 @@ public PageEnvelope<OnHandSummary> listOnHand(
6262
String code = pe != null ? pe.getCode() : ("P-" + e.getPartId());
6363
String name = pe != null ? pe.getName() : null;
6464
var partRef = new PartRef(e.getPartId(), code, name);
65-
var last = com.gearfirst.warehouse.common.util.DateTimes.toKstString(e.getLastUpdatedAt());
65+
var updatedAt = com.gearfirst.warehouse.common.util.DateTimes.toKstString(e.getLastUpdatedAt());
6666
int onHand = e.getOnHandQty() == null ? 0 : e.getOnHandQty();
6767
int safety = pe != null && pe.getSafetyStockQty() != null ? pe.getSafetyStockQty() : 0;
6868
boolean low = onHand < safety;
69-
items.add(new OnHandSummary(e.getWarehouseCode(), partRef, onHand, last, low, safety));
69+
String supplier = e.getSupplierName();
70+
Integer price = (pe != null ? pe.getPrice() : null);
71+
Integer priceTotal = (price == null ? null : Integer.valueOf(price.intValue() * onHand));
72+
items.add(new OnHandSummary(e.getWarehouseCode(), partRef, onHand, updatedAt, low, safety, supplier, price, priceTotal));
7073
}
71-
// Filters: partKeyword (code|name), supplierName (part attribution), qty range
74+
// Filters: partKeyword (code|name), supplierName (entity snapshot), qty range
7275
var filtered = items.stream()
7376
.filter(i -> partKeyword == null || partKeyword.isBlank()
7477
|| containsIgnoreCase(i.part().code(), partKeyword)
7578
|| containsIgnoreCase(i.part().name(), partKeyword))
76-
.filter(i -> {
77-
if (supplierName == null || supplierName.isBlank()) {
78-
return true;
79-
}
80-
var pe = partMap.get(i.part().id());
81-
var sname = (pe == null ? null : pe.getSupplierName());
82-
return containsIgnoreCase(sname, supplierName);
83-
})
79+
.filter(i -> supplierName == null || supplierName.isBlank() || containsIgnoreCase(i.supplierName(), supplierName))
8480
.filter(i -> minQty == null || i.onHandQty() >= minQty)
8581
.filter(i -> maxQty == null || i.onHandQty() <= maxQty)
8682
.toList();
8783

88-
// Sorting whitelist
84+
// Sorting whitelist (legacy default: partName, partCode)
8985
Comparator<OnHandSummary> cmp = Comparator
90-
.comparing((OnHandSummary s) -> s.part().name() == null ? "" : s.part().name(),
91-
String::compareToIgnoreCase)
92-
.thenComparing((OnHandSummary s) -> s.part().code() == null ? "" : s.part().code(),
93-
String::compareToIgnoreCase);
86+
.comparing((OnHandSummary s) -> s.part().name() == null ? "" : s.part().name(), String::compareToIgnoreCase)
87+
.thenComparing((OnHandSummary s) -> s.part().code() == null ? "" : s.part().code(), String::compareToIgnoreCase);
9488
if (sort != null && !sort.isEmpty()) {
9589
cmp = buildComparator(sort, cmp);
9690
}
@@ -102,6 +96,86 @@ public PageEnvelope<OnHandSummary> listOnHand(
10296
return PageEnvelope.of(sorted.subList(from, Math.max(from, to)), page, size, total);
10397
}
10498

99+
@Override
100+
@Transactional(readOnly = true)
101+
public PageEnvelope<OnHandSummary> listOnHandAdvanced(
102+
String q,
103+
Long partId,
104+
String partCode,
105+
String partName,
106+
String warehouseCode,
107+
String supplierName,
108+
Integer minQty,
109+
Integer maxQty,
110+
int page,
111+
int size,
112+
List<String> sort
113+
) {
114+
if (page < 0 || size < 1 || size > 200) {
115+
throw new BadRequestException(ErrorStatus.VALIDATION_REQUEST_MISSING_EXCEPTION);
116+
}
117+
if (minQty != null && maxQty != null && minQty > maxQty) {
118+
throw new BadRequestException(ErrorStatus.VALIDATION_REQUEST_MISSING_EXCEPTION);
119+
}
120+
List<InventoryOnHandEntity> entities = (warehouseCode == null || warehouseCode.isBlank())
121+
? repo.findAll()
122+
: repo.findAllByWarehouseCode(warehouseCode);
123+
124+
// Enrich with Part data
125+
var partIds = entities.stream().map(InventoryOnHandEntity::getPartId).distinct().toList();
126+
var partMap = new HashMap<Long, PartEntity>();
127+
if (!partIds.isEmpty()) {
128+
for (var p : parts.findAllById(partIds)) {
129+
partMap.put(p.getId(), p);
130+
}
131+
}
132+
var items = new ArrayList<OnHandSummary>(entities.size());
133+
for (var e : entities) {
134+
var pe = partMap.get(e.getPartId());
135+
String code0 = pe != null ? pe.getCode() : ("P-" + e.getPartId());
136+
String name0 = pe != null ? pe.getName() : null;
137+
var partRef = new PartRef(e.getPartId(), code0, name0);
138+
var updatedAt = com.gearfirst.warehouse.common.util.DateTimes.toKstString(e.getLastUpdatedAt());
139+
int onHand = e.getOnHandQty() == null ? 0 : e.getOnHandQty();
140+
int safety = pe != null && pe.getSafetyStockQty() != null ? pe.getSafetyStockQty() : 0;
141+
boolean low = onHand < safety;
142+
String supplier = e.getSupplierName();
143+
Integer price = (pe != null ? pe.getPrice() : null);
144+
Integer priceTotal = (price == null ? null : Integer.valueOf(price.intValue() * onHand));
145+
items.add(new OnHandSummary(e.getWarehouseCode(), partRef, onHand, updatedAt, low, safety, supplier, price, priceTotal));
146+
}
147+
148+
String qn = q == null ? null : q.trim();
149+
var filtered = items.stream()
150+
.filter(i -> qn == null || qn.isBlank() ||
151+
containsIgnoreCase(i.part().code(), qn) ||
152+
containsIgnoreCase(i.part().name(), qn) ||
153+
containsIgnoreCase(i.supplierName(), qn) ||
154+
containsIgnoreCase(i.warehouseCode(), qn))
155+
.filter(i -> partId == null || java.util.Objects.equals(i.part().id(), partId))
156+
.filter(i -> partCode == null || partCode.isBlank() || containsIgnoreCase(i.part().code(), partCode))
157+
.filter(i -> partName == null || partName.isBlank() || containsIgnoreCase(i.part().name(), partName))
158+
.filter(i -> warehouseCode == null || warehouseCode.isBlank() || java.util.Objects.equals(i.warehouseCode(), warehouseCode))
159+
.filter(i -> supplierName == null || supplierName.isBlank() || containsIgnoreCase(i.supplierName(), supplierName))
160+
.filter(i -> minQty == null || i.onHandQty() >= minQty)
161+
.filter(i -> maxQty == null || i.onHandQty() <= maxQty)
162+
.toList();
163+
164+
// Sorting: default updatedAt desc
165+
Comparator<OnHandSummary> cmp = Comparator.comparing(i -> i.updatedAt() == null ? "" : i.updatedAt());
166+
// reverse for desc
167+
cmp = cmp.reversed();
168+
if (sort != null && !sort.isEmpty()) {
169+
cmp = buildComparator(sort, null);
170+
}
171+
var sorted = filtered.stream().sorted(cmp).toList();
172+
173+
long total = sorted.size();
174+
int from = Math.min(page * size, (int) total);
175+
int to = Math.min(from + size, (int) total);
176+
return PageEnvelope.of(sorted.subList(from, Math.max(from, to)), page, size, total);
177+
}
178+
105179
private Comparator<OnHandSummary> buildComparator(List<String> sort, Comparator<OnHandSummary> defaultCmp) {
106180
Comparator<OnHandSummary> cmp = null;
107181
for (String s : sort) {
@@ -118,8 +192,14 @@ private Comparator<OnHandSummary> buildComparator(List<String> sort, Comparator<
118192
case "partCode" -> c = Comparator.comparing(i -> i.part().code() == null ? "" : i.part().code(),
119193
String::compareToIgnoreCase);
120194
case "onHandQty" -> c = Comparator.comparingInt(OnHandSummary::onHandQty);
195+
case "warehouseCode" -> c = Comparator.comparing(i -> i.warehouseCode() == null ? "" : i.warehouseCode(),
196+
String::compareToIgnoreCase);
197+
case "supplierName" -> c = Comparator.comparing(i -> i.supplierName() == null ? "" : i.supplierName(),
198+
String::compareToIgnoreCase);
121199
case "lastUpdatedAt" ->
122-
c = Comparator.comparing(i -> i.lastUpdatedAt() == null ? "" : i.lastUpdatedAt());
200+
c = Comparator.comparing(i -> i.updatedAt() == null ? "" : i.updatedAt());
201+
case "updatedAt" ->
202+
c = Comparator.comparing(i -> i.updatedAt() == null ? "" : i.updatedAt());
123203
default -> throw new BadRequestException(ErrorStatus.VALIDATION_REQUEST_MISSING_EXCEPTION);
124204
}
125205
if ("desc".equals(dir)) {

src/main/java/com/gearfirst/warehouse/api/shipping/service/ShippingServiceImpl.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,13 @@ public PageEnvelope<OnHandSummary> listOnHand(
574574
return PageEnvelope.of(List.of(), page, size, 0);
575575
}
576576

577+
@Override
578+
public PageEnvelope<OnHandSummary> listOnHandAdvanced(
579+
String q, Long partId, String partCode, String partName, String warehouseCode,
580+
String supplierName, Integer minQty, Integer maxQty, int page, int size, List<String> sort) {
581+
return PageEnvelope.of(List.of(), page, size, 0);
582+
}
583+
577584
@Override
578585
public void increase(String warehouseCode, Long partId, int qty) { /* no-op */ }
579586

0 commit comments

Comments
 (0)