Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/plans/2026-10-05-followup-quality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# 남은 조회 오동작 개선 계획

> 에이전트 작업: superpowers:dispatching-parallel-agents와 test-driven-development로 독립 파일 영역을 분담하고, 명세 검토 후 독립 품질 리뷰를 수행합니다.

**목표:** 사용자 승인한 R1–R4를 수정하고 운영 서버와 공개 CLI에서 원래 재현을 검증합니다.
**설계:** 상품과 매장 식별자를 유지해 선택한 매장의 관측값만 표시합니다. 실제 검색 페이지 계약을 확인해 Seven 페이지를 처리합니다. 비교의 부분 실패는 캐시에 저장하지 않으며 기존 부분 실패 캐시는 namespace 갱신으로 제거합니다.
**기술:** TypeScript/Hono/Zod/Vitest/Cloudflare Worker, Node CLI, 기존 Mac 중계.

## R1/R2 대화형 CLI

대상: src/cli/interactiveTypes.ts, src/utils/cliInteractiveHelpers.ts, src/cli/interactiveItemSearch.ts와 필요한 작은 helper; tests/app의 대화형 테스트.

- [x] 실제 API fixture로 OY 매장 재고10/O2O8/검색값0, 미확인/미판매/진짜0, CU 매장stock48/픽업false/상품픽업true를 실패 테스트로 작성하고 red 기록.
- [x] 매장 코드를 파싱·보존. 이름/address fallback은 정확한 매칭만 허용. 잘못된 매장으로 대체하지 않음.
- [x] OY는 선택한 상품·매장의 storeInventory만 수량/상태로 출력. 관측하지 못한 수량은 확인 불가.
- [x] CU는 선택 상품이 최초 stockItemCode와 다르면 해당 상품명으로 재조회하고 반환 상품코드 일치 확인. 선택 매장의 stock/채널 설정을 출력. 실패·미확인은 확인 불가이며 일반 상품 설정을 매장 값으로 사용하지 않음.
- [x] 기존 대화형 흐름과 새 선택/재조회 테스트 통과.

## R3 Seven 페이지

대상: src/services/seveneleven/client.ts, productKeyword.ts, 관련 테스트; 실제 계약 변경 시 scripts/relay/convenience.ts 및 중계 테스트.

- [x] 공식 공개 프런트엔드의 검색 요청 형식과 기존 중계 원시 페이지 응답을 확인. 페이지 계약을 추측해 구현하지 않음.
- [x] 페이지1/2/3의 상품코드 차이, 크기 한도, 총개수, 마지막 빈 페이지를 검증하는 red 테스트 작성.
- [x] 확인한 계약으로 client/relay 요청 구현. 서로 다른 컬렉션 병합·중복·변형 검색에도 페이지 중복과 이중 slicing 방지.
- [ ] 실제 운영과 중계 최소 호출로 동일 검색의 페이지별 코드를 대조. 기존 원장·인증 보존.

## R4 비교 실패 캐시

대상: src/utils/cache.ts, src/api/routes/compareRoutes.ts, src/api/compareHandlers.ts, src/cliRenderer.ts 및 tests/app/api/utils.

- [x] 실제 cache 저장소를 주입해 첫 partial failure 후 회복 결과 재조회가 실패하는 red 테스트 작성.
- [x] errors가 있는 비교 응답은 no-store 및 캐시 저장 생략. 다른 정상 조회의 캐시 동작은 보존. 필요한 경우 cache option의 응답 predicate로 해당 route에만 적용.
- [x] 비교 namespace 갱신으로 이전 partial result 제거. CLI에 서비스 부분 실패를 분명히 표시.
- [x] 실패/정상/null·잘못된 응답 및 기존 정상 cache 테스트 통과.

## 통합·리뷰·배포

- [x] 독립 명세 검토 및 품질 리뷰, check/전체 tests/coverage100/build/audit0 확인. src 파일450줄 이하.
- [ ] 한국어 커밋/PR, GitHub CI·Coverage·CodeQL 성공 확인 후 기존 사용자 승인에 따라 병합·Worker 배포.
- [ ] 중계 계약이 변경되면 소유 프로세스 종료 확인→런타임 교체→재기동·health·원장보존 검증. 변경 없으면 불필요 재기동하지 않음.
- [ ] CLI patch 릴리스·tag/gitHead·npm tarball무결성 확인, 공개 코드에서 OY/CU 실제 API fixture 재현과 Seven 페이지/비교 회복 검증.
- [ ] 보고서/PROJECT 갱신, 작업용 checkout 정리와 완료 알림.

공유 호출 예산: 편의점 일일 잔여 최소150회 유지, 매분 잔여 확인 후 필요한 최소 읽기 요청만 수행. 한도·원장·인증·Google/Zyte 설정 및 롯데마트 종료 정책은 바꾸지 않습니다. 메시지 제출 도구는 실제 발송하지 않습니다.
25 changes: 25 additions & 0 deletions docs/seveneleven-product-pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 세븐일레븐 상품 검색 페이지 계약

2026-10-05 공식 웹 앱의 `/search/result` 요청과 공개 API 응답을 대조했습니다. 이전의 `pageNo/pageSize` 요청은 페이지를 바꿔도 같은 상품을 반환했습니다.

공식 요청은 `/api/v1/open/search/goods`에 다음 본문을 POST합니다.

```json
{
"collection": "goods",
"query": "커피",
"sort": "quantity/desc,itemOnm/asc",
"startCount": 0,
"listCount": 100
}
```

`startCount`는 0부터 시작하는 페이지 번호입니다. 각 컬렉션은 따로 페이지를 반환합니다. 컬렉션 사이에 같은 상품이 있으므로 원본 `totalCount`의 합은 고유 상품 수와 다릅니다. 확인 당시 커피는 원본 166행, 고유 상품 122개였습니다.

검색은 원본 목록을 완전히 수집한 뒤 상품 코드로 중복을 제거하고, 보정 검색의 결과를 합쳐 한 번만 페이지를 나눕니다. 응답의 `totalCount`는 고유 상품 수입니다. 마지막 페이지 이후에는 같은 전체 개수와 빈 상품 목록을 반환합니다.

부하를 제한하기 위해 요청당 컬렉션별 100행, 검색어당 최대 5페이지와 원본 합계 500행, 보정 검색 전체 최대 10요청을 허용합니다. 범위가 이 제한을 넘거나 페이지가 반복·누락되면 불완전한 목록을 정상 결과로 반환하지 않습니다. REST는 오류 응답, MCP는 명시적인 `degraded` 응답을 반환합니다. 넓은 검색은 상품명을 더 구체적으로 입력해야 합니다.

Mac 중계는 구형 본문과 공식 본문을 함께 허용합니다. 중계를 먼저 반영한 뒤 Worker를 배포합니다. 중계 인증·GS25 세션·호출 원장은 교체하거나 초기화하지 않습니다. REST 검색 캐시 namespace는 `seveneleven-products-v3`로 갱신했습니다.

확인한 공식 소스: [검색 화면 번들](https://static.7-elevenapp.co.kr/k7app/_nuxt/DH1pJlb5.js), [검색 API 번들](https://static.7-elevenapp.co.kr/k7app/_nuxt/BqIULyu_.js).
5 changes: 4 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1141,18 +1141,21 @@
},
"remainQuantity": {
"type": "integer",
"nullable": true,
"example": 3
},
"o2oRemainQuantity": {
"type": "integer",
"nullable": true,
"example": 2
},
"stockStatus": {
"type": "string",
"enum": [
"in_stock",
"out_of_stock",
"not_sold"
"not_sold",
"unknown"
],
"example": "in_stock"
},
Expand Down
3 changes: 3 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -814,16 +814,19 @@ components:
example: true
remainQuantity:
type: integer
nullable: true
example: 3
o2oRemainQuantity:
type: integer
nullable: true
example: 2
stockStatus:
type: string
enum:
- in_stock
- out_of_stock
- not_sold
- unknown
example: in_stock
stockLabel:
type: string
Expand Down
10 changes: 8 additions & 2 deletions scripts/relay/convenience.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@ const schemas = {
[v.latVal, v.baseLatVal].every((n) => Math.abs(Number(n)) <= 90) &&
[v.longVal, v.baseLongVal].every((n) => Math.abs(Number(n)) <= 180),
),
'seven-goods': z.object({ query: text.min(1), pageNo: count, pageSize: count.min(1) }).strict(),
'seven-goods': z.union([
z.object({ collection: z.literal('goods'), query: text.min(1), sort: z.literal('quantity/desc,itemOnm/asc'), startCount: count, listCount: count.min(1) }).strict(),
z.object({ query: text.min(1), pageNo: count, pageSize: count.min(1) }).strict(),
]),
'seven-store': z
.object({
collection: z.literal('store'),
Expand Down Expand Up @@ -246,7 +249,10 @@ const paths: Record<Operation, [string, string, 'GET' | 'POST', z.ZodType]> = {
};
export function createConvenienceRelay(
token: string,
options: Pick<HttpRelayOptions, 'takeQuota' | 'fetcher' | 'onEvent'> & { gs25ApiKey?: string; gs25Session?: Gs25SessionTransport },
options: Pick<HttpRelayOptions, 'takeQuota' | 'fetcher' | 'onEvent'> & {
gs25ApiKey?: string;
gs25Session?: Gs25SessionTransport;
},
) {
return createHttpRelay(token, {
...options,
Expand Down
15 changes: 14 additions & 1 deletion src/api/routes/compareRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,20 @@ export function registerCompareRoutes(app: Hono<{ Bindings: AppBindings }>): voi
{
ttlSeconds: 60 * 10,
staleWhileRevalidateSeconds: 60,
keyPrefix: 'compare-products-v2',
keyPrefix: 'compare-products-v3',
shouldCache: async (response) => {
try {
const payload = await response.json() as {
success?: unknown;
data?: { results?: unknown; errors?: unknown };
} | null;
return response.ok && payload?.success === true &&
Array.isArray(payload.data?.results) &&
Array.isArray(payload.data?.errors) && payload.data.errors.length === 0;
} catch {
return false;
}
},
},
() => handleCompareProducts(c),
),
Expand Down
2 changes: 1 addition & 1 deletion src/api/routes/oliveyoungRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function registerOliveyoungRoutes(app: Hono<{ Bindings: AppBindings }>):
{
ttlSeconds: 60 * 10,
staleWhileRevalidateSeconds: 60,
keyPrefix: 'oliveyoung-inventory-v3',
keyPrefix: 'oliveyoung-inventory-v4',
},
() => handleOliveyoungCheckInventory(c),
),
Expand Down
2 changes: 1 addition & 1 deletion src/api/routes/sevenelevenRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function registerSevenElevenRoutes(app: Hono<{ Bindings: AppBindings }>):
{
ttlSeconds: 60 * 3,
staleWhileRevalidateSeconds: 60,
keyPrefix: 'seveneleven-products-v2',
keyPrefix: 'seveneleven-products-v3',
},
() => handleSevenElevenSearchProducts(c),
),
Expand Down
46 changes: 33 additions & 13 deletions src/cli/interactiveItemSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,19 @@ import { isRecord, parseDaisoProducts, toText } from '../utils/cliInteractiveHel
import type { InteractiveCliDeps, InteractivePrompt, InteractiveStore, InteractiveTheater, WriteFn } from './interactiveTypes.js';
import { askNonEmpty } from './interactivePrompt.js';
import { fetchEnvelope } from './interactiveFetch.js';
import { cuStoreStock, oliveyoungStoreStock, printCuStock, printOliveyoungStock, quantityText } from './interactiveStoreInventory.js';

interface OliveyoungProductPreview {
goodsNumber: string;
goodsName: string;
priceToPay: number;
o2oRemainQuantity: number;
source: Record<string, unknown>;
}

interface CuInventoryPreview {
itemCode: string;
itemName: string;
price: number;
pickupYn: boolean;
deliveryYn: boolean;
reserveYn: boolean;
}

interface LotteCinemaMoviePreview {
Expand Down Expand Up @@ -245,7 +243,7 @@ export async function runOliveyoungItemSearch(
goodsNumber: toText(entry.goodsNumber),
goodsName: toText(entry.goodsName),
priceToPay: Number.parseInt(toText(entry.priceToPay), 10) || 0,
o2oRemainQuantity: Number.parseInt(toText(entry.o2oRemainQuantity), 10) || 0,
source: entry,
}))
.filter((entry) => entry.goodsName.length > 0);

Expand All @@ -267,7 +265,7 @@ export async function runOliveyoungItemSearch(
cancelText: '상품 선택을 취소했습니다.',
items: products,
renderItem: (product, index) =>
`${index + 1}. ${product.goodsName} (${product.priceToPay}원, 남은수량 ${product.o2oRemainQuantity})`,
`${index + 1}. ${product.goodsName} (${product.priceToPay}원, 남은수량 ${quantityText(oliveyoungStoreStock(product.source, store)?.remainQuantity)})`,
filterText: (product) => `${product.goodsName} ${product.goodsNumber}`,
indexText: '입력: 번호 선택 | /키워드 필터 | all 전체보기 | 0 취소',
});
Expand All @@ -278,7 +276,23 @@ export async function runOliveyoungItemSearch(

deps.writeOut(`- 상품: ${selected.goodsName}`);
deps.writeOut(`- 가격: ${selected.priceToPay}원`);
deps.writeOut(`- 남은수량: ${selected.o2oRemainQuantity}`);
let match = oliveyoungStoreStock(selected.source, store);
if (!isRecord(selected.source.storeInventory) && selected.goodsNumber) {
try {
const refreshed = await fetchEnvelope(deps.fetchImpl, '/api/oliveyoung/inventory', {
keyword: selected.goodsName, storeKeyword: store.name, size: '10',
});
if (isRecord(refreshed) && refreshed.success === true && isRecord(refreshed.data)
&& isRecord(refreshed.data.inventory) && Array.isArray(refreshed.data.inventory.products)) {
const product = refreshed.data.inventory.products.find((entry: unknown) =>
isRecord(entry) && toText(entry.goodsNumber) === selected.goodsNumber);
match = oliveyoungStoreStock(product, store);
}
} catch {
// 재조회 실패는 품절로 단정하지 않는다.
}
}
printOliveyoungStock(deps.writeOut, match);
}

export async function runCuItemSearch(
Expand Down Expand Up @@ -311,9 +325,6 @@ export async function runCuItemSearch(
itemCode: toText(entry.itemCode),
itemName: toText(entry.itemName),
price: Number.parseInt(toText(entry.price), 10) || 0,
pickupYn: toText(entry.pickupYn).toLowerCase() === 'true',
deliveryYn: toText(entry.deliveryYn).toLowerCase() === 'true',
reserveYn: toText(entry.reserveYn).toLowerCase() === 'true',
}))
.filter((entry) => entry.itemName.length > 0);

Expand Down Expand Up @@ -345,7 +356,16 @@ export async function runCuItemSearch(

deps.writeOut(`- 상품: ${selected.itemName}`);
deps.writeOut(`- 가격: ${selected.price}원`);
deps.writeOut(`- 픽업 가능: ${selected.pickupYn ? '예' : '아니오'}`);
deps.writeOut(`- 배달 가능: ${selected.deliveryYn ? '예' : '아니오'}`);
deps.writeOut(`- 예약 가능: ${selected.reserveYn ? '예' : '아니오'}`);
let stockPayload: unknown = payload;
const nearby = payload.data.nearbyStores;
if (!isRecord(nearby) || nearby.stockItemCode !== selected.itemCode) {
try {
stockPayload = await fetchEnvelope(deps.fetchImpl, '/api/cu/inventory', {
keyword: selected.itemName, storeKeyword: store.name, size: '10', storeLimit: '10',
});
} catch {
stockPayload = undefined;
}
}
printCuStock(deps.writeOut, cuStoreStock(stockPayload, selected.itemCode, store));
}
57 changes: 57 additions & 0 deletions src/cli/interactiveStoreInventory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* 선택 매장의 검증된 재고와 채널 표시
*/
import { isRecord, toText } from '../utils/cliInteractiveHelpers.js';
import type { InteractiveStore, WriteFn } from './interactiveTypes.js';

export function matchSelectedStore(stores: unknown, store: InteractiveStore): Record<string, unknown> | undefined {
if (!Array.isArray(stores)) return undefined;
const entries = stores.filter((entry): entry is Record<string, unknown> => isRecord(entry));
const codeMatches = store.storeCode
? entries.filter((entry) => toText(entry.storeCode) === store.storeCode)
: [];
if (codeMatches.length === 1) return codeMatches[0];
if (codeMatches.length > 1) return undefined;
const matches = entries.filter((entry) => {
if (store.storeCode && toText(entry.storeCode)) return false;
return store.address.length > 0 && toText(entry.address) === store.address
&& (toText(entry.storeName) || toText(entry.name)) === store.name;
});
return matches.length === 1 ? matches[0] : undefined;
}

export function quantityText(value: unknown): string {
const quantity = typeof value === 'number' ? value
: typeof value === 'string' && value.trim() ? Number(value) : NaN;
return Number.isInteger(quantity) && quantity >= 0 ? String(quantity) : '확인 불가';
}

export function oliveyoungStoreStock(product: unknown, store: InteractiveStore): Record<string, unknown> | undefined {
if (!isRecord(product) || !isRecord(product.storeInventory)) return undefined;
return matchSelectedStore(product.storeInventory.stores, store);
}

export function printOliveyoungStock(writeOut: WriteFn, match: Record<string, unknown> | undefined): void {
const status = match?.stockStatus;
const label = status === 'in_stock' ? '재고 있음' : status === 'out_of_stock' ? '품절'
: status === 'not_sold' ? '미취급' : '확인 불가';
writeOut(`- 남은수량: ${quantityText(match?.remainQuantity)}`);
writeOut(`- O2O 남은수량: ${quantityText(match?.o2oRemainQuantity)}`);
writeOut(`- 재고 상태: ${label}`);
}

export function cuStoreStock(payload: unknown, itemCode: string, store: InteractiveStore): Record<string, unknown> | undefined {
if (!isRecord(payload) || payload.success !== true || !isRecord(payload.data)) return undefined;
const nearby = payload.data.nearbyStores;
if (!isRecord(nearby) || nearby.available === false || !itemCode || nearby.stockItemCode !== itemCode) return undefined;
return matchSelectedStore(nearby.stores, store);
}

export function printCuStock(writeOut: WriteFn, match: Record<string, unknown> | undefined): void {
writeOut(`- 재고 수량: ${quantityText(match?.stock)}`);
const hasVerifiedStock = quantityText(match?.stock) !== '확인 불가';
for (const [field, label] of [['pickupYn', '픽업'], ['deliveryYn', '배달'], ['reserveYn', '예약']]) {
const value = hasVerifiedStock ? match?.[field] : undefined;
writeOut(`- ${label} 가능: ${value === true ? '예' : value === false ? '아니오' : '확인 불가'}`);
}
}
1 change: 1 addition & 0 deletions src/cli/interactiveTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface InteractivePrompt {
}

export interface InteractiveStore {
storeCode?: string;
name: string;
address: string;
phone: string;
Expand Down
15 changes: 13 additions & 2 deletions src/cliRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,14 @@ function formatCompare(data: Record<string, unknown>): string[] {
);
}
lines.push(...formatCollection('가격 후보', data.results));
lines.push(...formatCollection('오류', data.errors));
if (Array.isArray(data.errors) && data.errors.length > 0) {
lines.push(`서비스 조회 실패: ${data.errors.length}건`);
for (const error of data.errors) {
lines.push(isRecord(error)
? `- ${toText(error.service)}: ${toText(error.message)}`
: `- ${toText(error)}`);
}
}
if (typeof data.note === 'string') {
lines.push(`참고: ${data.note}`);
}
Expand All @@ -248,7 +255,11 @@ export function renderApiEnvelope(command: string, url: URL, payload: unknown):
return JSON.stringify(payload, null, 2);
}

const lines: string[] = [`요청 성공: ${url.pathname}`];
const partialCompare = command === 'compare' && isRecord(envelope.data) &&
Array.isArray(envelope.data.errors) && envelope.data.errors.length > 0;
const lines: string[] = [
`${partialCompare ? '일부 서비스 조회 실패' : '요청 성공'}: ${url.pathname}`,
];
lines.push(...formatMeta(envelope.meta));

if (!isRecord(envelope.data)) {
Expand Down
6 changes: 3 additions & 3 deletions src/pages/openapiSpecComponentsDaisoOliveyoung.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,9 @@ export const OPENAPI_DAISO_OLIVEYOUNG_COMPONENT_SCHEMAS = {
distance: { type: 'number', format: 'float', example: 0.07 },
pickupYn: { type: 'boolean', example: true },
salesStoreYn: { type: 'boolean', example: true },
remainQuantity: { type: 'integer', example: 3 },
o2oRemainQuantity: { type: 'integer', example: 2 },
stockStatus: { type: 'string', enum: ['in_stock', 'out_of_stock', 'not_sold'], example: 'in_stock' },
remainQuantity: { type: 'integer', nullable: true, example: 3 },
o2oRemainQuantity: { type: 'integer', nullable: true, example: 2 },
stockStatus: { type: 'string', enum: ['in_stock', 'out_of_stock', 'not_sold', 'unknown'], example: 'in_stock' },
stockLabel: { type: 'string', example: '재고 3개' },
openYn: { type: 'boolean', example: true },
},
Expand Down
Loading
Loading