mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Feature/#207: Restore actionable Daiso pickup answer via selPkupStr fallback (#215)
* Restore actionable Daiso pickup answer when store pickup stock is blocked Adds a public selPkupStr-backed getStorePickupEligibility() helper plus a new pickupEligibility field on lookupStoreProductAvailability(). When selStrPkupStck still returns 401/403 Unauthorized as in #207, the package now reports whether the selected store is registered as a pickup-capable store for the product (pickupEligible: true|false|null), instead of only returning blocked/unknown. Closes #207 * Make scope limits explicit in skill description and feature doc Clarify across three high-traffic surfaces that this skill no longer returns exact per-store stock quantities while the official Daiso selStrPkupStck endpoint stays Unauthorized: only pickup eligibility (yes/no) is reported in that state. - daiso-product-search/SKILL.md frontmatter description rewritten so coding agents see the limit before triggering the skill - daiso-product-search/SKILL.md adds explicit Scope and limits section plus reworked When to use / When not to use examples - docs/features/daiso-product-search.md adds a new "이 기능으로 할 수 없는 일" section listing the quantity gap - root README.md row clarifies the skill answers pickup eligibility, not exact per-store quantities, while the upstream block holds * Prevent under-scoped Daiso pickup negatives Return an explicit insufficient-coverage eligibility state when selPkupStr search input cannot prove absence, and require pkupYn=Y for positive eligibility. This preserves the actionable fallback while avoiding false negatives from broad or missing store keywords. Constraint: Existing PR #215 already added selPkupStr fallback; this follow-up is limited to review-requested correctness fixes. Rejected: Treating a missing first-page match as definitive false | broad or unkeyed selPkupStr searches can miss the target store. Confidence: high Scope-risk: narrow Directive: Do not claim pickup ineligibility unless the searched selPkupStr coverage is sufficient to prove absence. Tested: npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live Daiso smoke for 10224, missing keyword, and negative 99999. Not-tested: Exhaustive multi-page live pagination across all Daiso store keywords. Co-authored-by: OmX <omx@oh-my-codex.dev> * Keep Daiso pickup fallback shape actionable Stabilize blocked pickupEligibility responses with matchedStore:null and keep optional online-stock failures from preventing the selPkupStr pickup-eligibility fallback. This preserves the core store/product/pickup answer even when reference-only online stock is unavailable. Constraint: Issue #207 requires an actionable pickup answer when the pickup-stock endpoint is blocked, and PR review required stable public response shape. Rejected: Letting optional online stock reject the end-to-end helper | it can defeat the new actionable fallback even though online stock is reference-only. Confidence: high Scope-risk: narrow Directive: Keep quantity-bearing pickupStock separate from quantity-free pickupEligibility, and do not let optional enrichments block core pickup fallback results. Tested: npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live Daiso smoke for 10224, missing keyword, negative 99999, and end-to-end lookup. Not-tested: Exhaustive live multi-page selPkupStr pagination across every store keyword. --------- Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
parent
f527515932
commit
af55f58cb4
10 changed files with 682 additions and 20 deletions
5
.changeset/issue-207-daiso-pickup-eligibility.md
Normal file
5
.changeset/issue-207-daiso-pickup-eligibility.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"daiso-product-search": minor
|
||||
---
|
||||
|
||||
Restore actionable Daiso pickup answers when store pickup stock is blocked by adding a `selPkupStr`-backed `getStorePickupEligibility()` helper plus `pickupEligibility` field on `lookupStoreProductAvailability()`. When pickup stock returns `Unauthorized`, the package now reports whether the selected store is registered as a pickup-capable store for the product instead of only saying "unknown".
|
||||
|
|
@ -67,7 +67,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~`blue-ribbon-nearby`~~ | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
|
||||
| 근처 술집 조회 | `kakao-bar-nearby` | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
|
||||
| 우편번호 검색 | `zipcode-search` | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
|
||||
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
|
||||
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 픽업 가능 여부 확인 (정확한 매장별 재고 수량은 다이소몰 보안 정책으로 2026-05-05 부터 차단됨) | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
|
||||
| 마켓컬리 상품 조회 | `market-kurly-search` | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
|
||||
| 올리브영 검색 | `olive-young-search` | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
|
||||
| 올라포케 역삼 포케 | `hola-poke-yeoksam` | 올라포케 역삼점 메뉴, 매장 정보, 이벤트 참여 흐름 안내 | 불필요 | [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md) |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: daiso-product-search
|
||||
description: Look up Daiso products by store name and product keyword using official Daiso Mall store/search/stock surfaces. Use when the user wants to know whether a product is available at a specific Daiso store.
|
||||
description: Look up Daiso products by store name and product keyword using official Daiso Mall store/search/stock surfaces. Reports whether a product is registered as pickup-eligible at a specific Daiso store; the official store-level pickup quantity API has been blocked since 2026-05-05, so exact per-store stock counts are unavailable while that block remains.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: retail
|
||||
|
|
@ -18,20 +18,30 @@ metadata:
|
|||
- 공식 상품 검색으로 상품 후보를 찾는다.
|
||||
- 공식 매장 픽업 재고 표면으로 해당 매장의 재고를 확인한다.
|
||||
- 다이소몰이 매장 픽업 재고 표면을 `Unauthorized` 로 차단하면 차단 상태를 그대로 보고하고 세션 우회는 시도하지 않는다.
|
||||
- 매장 픽업 재고가 차단되면 공식 픽업 가능 매장 목록(`selPkupStr`) 으로 해당 매장에 상품이 픽업 가능 매장으로 등록되어 있는지 여부를 확인해 `pickupEligibility` 로 답한다. 정확한 수량은 여전히 알 수 없다.
|
||||
- **공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로만 답한다.**
|
||||
|
||||
## When to use
|
||||
|
||||
- "강남역2호점에 리들샷 있어?"
|
||||
- "다이소 특정 매장 재고 확인해줘"
|
||||
- "이 상품 어느 매장에 있는지 확인해줘"
|
||||
- "다이소 매장명 주면 그 매장 재고 봐줘"
|
||||
- "강남역2호점에서 리들샷 픽업 가능해?" (픽업 가능 여부 확인)
|
||||
- "이 상품 어느 매장에서 픽업 가능한지 확인해줘" (픽업 가능 매장 목록)
|
||||
- "다이소 매장명 주면 그 매장에서 살 수 있는지 봐줘"
|
||||
- 공식 매장 픽업 재고 API 가 응답하면 수량까지, 차단되면 픽업 가능 여부(yes/no)까지
|
||||
|
||||
## When not to use
|
||||
|
||||
- **"강남역2호점에 리들샷 몇 개 있어?"** 처럼 정확한 재고 수량을 보장해야 하는 경우 — 2026-05-05 부터 공식 매장 픽업 재고 API 가 `Unauthorized` 로 차단되어 수량을 답할 수 없다.
|
||||
- 매장명도 상품명도 전혀 없는 상태에서 바로 재고를 단정해야 하는 경우
|
||||
- 결제/주문/픽업 예약까지 자동화해야 하는 경우
|
||||
- 비공식 크롤링 결과를 우선해야 하는 경우
|
||||
- 매장 내 진열 위치(aisle/매대)를 알려줘야 하는 경우
|
||||
- 비공식 크롤링·세션 우회·계정 로그인 우회 결과를 사용해야 하는 경우
|
||||
|
||||
## Scope and limits (must read before answering)
|
||||
|
||||
- `pickupStock` 이 `retrievalStatus: "resolved"` 로 응답하면 정확한 매장 픽업 재고 수량을 줄 수 있다.
|
||||
- `pickupStock` 이 `retrievalStatus: "blocked"` 면 수량은 더 이상 답하지 않는다. `pickupEligibility.pickupEligible` 로 그 매장에서 픽업 가능한 상품인지(yes/no)만 답한다.
|
||||
- `onlineStock` 은 `referenceOnly: true` 다이소몰 온라인몰 재고 참고값일 뿐 매장 재고가 아니다. 매장 재고처럼 단정하지 않는다.
|
||||
- 차단 우회는 시도하지 않는다.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
@ -64,6 +74,7 @@ metadata:
|
|||
- product search list: `https://www.daisomall.co.kr/ssn/search/SearchGoods`
|
||||
- product summary list: `https://www.daisomall.co.kr/ssn/search/GoodsMummResult`
|
||||
- store pickup stock: `https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck`
|
||||
- store pickup eligibility (pickup-capable stores for a product): `https://www.daisomall.co.kr/api/ms/msg/selPkupStr`
|
||||
- optional online stock cross-check: `https://www.daisomall.co.kr/api/pdo/selOnlStck`
|
||||
|
||||
## Workflow
|
||||
|
|
@ -122,7 +133,25 @@ console.log(stock)
|
|||
// 차단 예시: { status: "unavailable", retrievalStatus: "blocked", inventoryStatus: "unknown", reason: "unauthorized", quantity: null, inStock: null }
|
||||
```
|
||||
|
||||
### 4. Use the end-to-end helper when both names are already known
|
||||
### 4. Fall back to pickup eligibility when stock is blocked
|
||||
|
||||
매장 픽업 재고가 `Unauthorized` 로 차단되면 공식 픽업 가능 매장 목록 표면으로 **해당 매장이 그 상품의 픽업 가능 매장에 들어 있는지** 만이라도 확인할 수 있다. 수량은 알 수 없지만 "그 매장에서 이 상품을 픽업으로 살 수 있는지" 는 답할 수 있다.
|
||||
|
||||
```js
|
||||
const { getStorePickupEligibility } = require("daiso-product-search")
|
||||
|
||||
const eligibility = await getStorePickupEligibility({
|
||||
pdNo: "1049275",
|
||||
strCd: "10224",
|
||||
storeName: "강남역2호점"
|
||||
})
|
||||
|
||||
console.log(eligibility)
|
||||
```
|
||||
|
||||
`pickupEligible` 가 `true` 이면 그 매장에서 픽업 가능, `false` 면 픽업 불가, `null` 이면 확인 불가다. `false` 는 검색 범위가 충분할 때만 확정값으로 해석한다. `retrievalStatus: "insufficient_coverage"` 는 매장명/키워드가 없거나 첫 페이지가 전체 결과를 덮지 못해 부재를 증명하지 못했다는 뜻이다. `eligibleStoreCount` 와 `eligibleStores` 로 다른 후보 매장도 함께 보여줄 수 있다.
|
||||
|
||||
### 5. Use the end-to-end helper when both names are already known
|
||||
|
||||
```js
|
||||
const { lookupStoreProductAvailability } = require("daiso-product-search")
|
||||
|
|
@ -135,15 +164,19 @@ const result = await lookupStoreProductAvailability({
|
|||
console.log(result.selectedStore)
|
||||
console.log(result.selectedProduct)
|
||||
console.log(result.pickupStock)
|
||||
console.log(result.pickupEligibility)
|
||||
```
|
||||
|
||||
### 5. Respond conservatively
|
||||
`pickupStock.retrievalStatus === "blocked"` 일 때만 `pickupEligibility` 가 채워진다. `includePickupEligibility: false` 옵션으로 끌 수 있다.
|
||||
|
||||
### 6. Respond conservatively
|
||||
|
||||
응답은 짧고 명확하게 정리한다.
|
||||
|
||||
- 매장명
|
||||
- 상품명
|
||||
- 매장 재고 수량, 재고 없음, 또는 `retrievalStatus: "blocked"` / `Unauthorized` 로 인한 확인 불가
|
||||
- 픽업 재고가 차단된 경우 `pickupEligibility.pickupEligible` 로 그 매장의 픽업 가능 여부만이라도 표시
|
||||
- 필요하면 `referenceOnly: true` 로 표시된 온라인 재고 참고값
|
||||
- **공식 표면이 매장 내 진열 위치를 주지 않으면 `공식 표면에서는 매장 재고까지만 확인된다`고 분명히 말한다.**
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,17 @@
|
|||
|
||||
- 다이소 매장명으로 공식 매장 후보 찾기
|
||||
- 상품명/검색어로 공식 상품 후보 찾기
|
||||
- 특정 매장의 **매장 픽업 재고** 확인
|
||||
- 매장 픽업 재고가 `Unauthorized` 로 차단되면 `retrievalStatus: "blocked"` 차단 상태를 명확히 표시하고, 필요하면 `referenceOnly: true` 온라인 재고 참고값 함께 확인
|
||||
- 특정 매장의 **매장 픽업 재고 수량** 확인 (공식 `selStrPkupStck` 표면이 응답할 때 한정)
|
||||
- 매장 픽업 재고가 `Unauthorized` 로 차단되면 `retrievalStatus: "blocked"` 차단 상태를 명확히 표시하고, 공식 픽업 가능 매장 목록(`selPkupStr`)으로 그 매장의 **픽업 가능 여부(yes/no)** 만이라도 `pickupEligibility` 로 확인
|
||||
- 필요하면 `referenceOnly: true` 온라인 재고 참고값 함께 확인
|
||||
|
||||
## 이 기능으로 할 수 없는 일 (스킬 범위 한계)
|
||||
|
||||
- **`selStrPkupStck` 가 차단된 동안에는 정확한 매장별 재고 수량을 답할 수 없습니다.** 2026-05-05 부터 공식 매장 픽업 재고 API 가 `Unauthorized (401/403)` 로 차단되어 있고, 이 스킬은 세션 우회·CAPTCHA 우회·로그인 강제 등 anti-bot 우회를 시도하지 않습니다.
|
||||
- 차단 상태에서는 `pickupEligibility.pickupEligible` 로 "그 매장이 그 상품의 픽업 가능 매장으로 등록되어 있는지(yes/no)" 까지만 답합니다. **수량(예: "3개 남음")은 답하지 않습니다.**
|
||||
- 매장 내 진열 위치(aisle/매대)는 공식 표면이 제공하지 않으므로 답하지 않습니다.
|
||||
- 결제·주문·픽업 예약 자동화는 범위가 아닙니다.
|
||||
- 비공식 크롤링·헤드리스 브라우저 우회·계정 세션 재사용은 범위가 아닙니다.
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
|
|
@ -28,6 +37,7 @@
|
|||
- product search list: `https://www.daisomall.co.kr/ssn/search/SearchGoods`
|
||||
- product summary list: `https://www.daisomall.co.kr/ssn/search/GoodsMummResult`
|
||||
- store pickup stock: `https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck`
|
||||
- store pickup eligibility (특정 상품의 픽업 가능 매장 목록): `https://www.daisomall.co.kr/api/ms/msg/selPkupStr`
|
||||
- optional online stock: `https://www.daisomall.co.kr/api/pdo/selOnlStck`
|
||||
|
||||
## 기본 흐름
|
||||
|
|
@ -38,8 +48,9 @@
|
|||
4. `SearchGoods` 로 상품 후보를 찾습니다.
|
||||
5. `selStrPkupStck` 로 해당 매장의 상품 재고를 확인합니다.
|
||||
6. `selStrPkupStck` 가 `Unauthorized` 로 차단되면 매장 픽업 재고는 `unavailable/blocked/unauthorized` 로 보고하고 세션 우회를 시도하지 않습니다.
|
||||
7. 필요하면 `SearchGoods` 응답의 `onldPdNo` 를 함께 보존해 `selOnlStck` 온라인 재고 교차 확인에 사용합니다.
|
||||
8. 공식 표면이 매장 내 위치를 주지 않으면 재고 중심으로 답합니다.
|
||||
7. 6번 차단이 발생하면 공식 `selPkupStr` 표면으로 그 상품의 **픽업 가능 매장 목록**을 받아 사용자가 고른 매장이 그 안에 들어 있는지(=`pickupEligibility.pickupEligible`) 만이라도 답합니다. 수량은 여전히 알 수 없습니다.
|
||||
8. 필요하면 `SearchGoods` 응답의 `onldPdNo` 를 함께 보존해 `selOnlStck` 온라인 재고 교차 확인에 사용합니다.
|
||||
9. 공식 표면이 매장 내 위치를 주지 않으면 재고 중심으로 답합니다.
|
||||
|
||||
## 예시
|
||||
|
||||
|
|
@ -56,6 +67,7 @@ async function main() {
|
|||
store: result.selectedStore,
|
||||
product: result.selectedProduct,
|
||||
pickupStock: result.pickupStock,
|
||||
pickupEligibility: result.pickupEligibility,
|
||||
onlineStock: result.onlineStock
|
||||
})
|
||||
}
|
||||
|
|
@ -74,6 +86,7 @@ main().catch((error) => {
|
|||
- 공식 표면이 매장 내 위치를 주지 않으면 `공식 표면에서는 매장 재고까지만 확인된다`고 답합니다.
|
||||
- 매장 픽업 재고의 `status` 는 조회 결과 범주입니다. 상품 재고 여부는 `inStock` 또는 `inventoryStatus` 로 설명하고, `status: "available"` 만으로 재고가 있다고 말하지 않습니다.
|
||||
- 매장 픽업 재고가 `Unauthorized` 로 차단된 경우에는 `다이소몰이 현재 매장 픽업 재고 API를 차단해 정확한 매장 재고 수량은 확인할 수 없다`고 답하고, 결과의 `retrievalStatus: "blocked"` 와 온라인 재고의 `referenceOnly: true` 참고값을 구분합니다.
|
||||
- 픽업 재고가 차단되어도 `pickupEligibility.pickupEligible === true` 면 `이 상품은 해당 매장의 픽업 가능 매장 목록에 등록되어 있어 픽업 자체는 가능합니다. 다만 정확한 수량은 확인할 수 없습니다.` 정도로 보수적으로 답합니다. `pickupEligible === false` 면 `해당 매장은 이 상품의 픽업 가능 매장에 등록되어 있지 않습니다.` 라고 답합니다. `null` 이면 차단 또는 `insufficient_coverage` 로 확인 불가로 답하고, 특히 검색 키워드가 없거나 첫 페이지가 전체 결과를 덮지 못한 경우에는 불가로 단정하지 않습니다.
|
||||
|
||||
## 라이브 확인 메모
|
||||
|
||||
|
|
@ -85,4 +98,5 @@ main().catch((error) => {
|
|||
- `GET /ssn/search/SearchGoods?searchTerm=...` → 상품 후보 및 `onldPdNo` 확인
|
||||
- `POST /api/pd/pdh/selStrPkupStck` → 성공하면 `status: "available"`, `retrievalStatus: "resolved"` 로 조회 성공을 표시하고, 실제 재고 여부는 `inStock` / `inventoryStatus` 로 표시
|
||||
- `selStrPkupStck` 가 `401`/`403` 또는 `{ "success": false, "message": "Unauthorized" }` 를 반환하면 `status: "unavailable"`, `retrievalStatus: "blocked"`, `inventoryStatus: "unknown"`, `reason: "unauthorized"` 로 표시
|
||||
- `POST /api/ms/msg/selPkupStr` → 픽업 재고가 차단되면 호출. 매장 픽업 가능 매장 목록을 받아 `pickupEligibility.pickupEligible`(true/false/null), `eligibleStoreCount`, `eligibleStores`, `matchedStore`, `searchedKeyword`, `totalCount` 로 응답 (수량 미제공). 검색 범위가 불충분하면 `retrievalStatus: "insufficient_coverage"` 와 `pickupEligible: null` 을 반환합니다.
|
||||
- `POST /api/pdo/selOnlStck` → 가능한 경우 온라인 재고 참고값 표시
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@
|
|||
- 다이소몰 상품 검색 목록: https://www.daisomall.co.kr/ssn/search/SearchGoods
|
||||
- 다이소몰 상품 요약 목록: https://www.daisomall.co.kr/ssn/search/GoodsMummResult
|
||||
- 다이소몰 매장 픽업 재고: https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck (2026-05-05 기준 Unauthorized 차단 가능)
|
||||
- 다이소몰 매장 픽업 가능 매장 목록: https://www.daisomall.co.kr/api/ms/msg/selPkupStr (특정 상품의 픽업 가능 매장 리스트, 매장 수량은 미제공)
|
||||
- 다이소몰 온라인 재고: https://www.daisomall.co.kr/api/pdo/selOnlStck
|
||||
- 마켓컬리 검색 API(v4): https://api.kurly.com/search/v4/sites/market/normal-search
|
||||
- 마켓컬리 검색 개수 API(v3): https://api.kurly.com/search/v3/sites/market/normal-search/count
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ npm install
|
|||
- 매장명과 상품명 둘 다 필요합니다.
|
||||
- 공식 다이소몰 표면을 우선 사용합니다.
|
||||
- 현재 확인된 공식 표면은 **매장 픽업 재고**를 제공하지만, 다이소몰 보안 정책에 따라 `Unauthorized` 로 차단될 수 있습니다.
|
||||
- 매장 픽업 재고가 차단되면 `pickupStock.status === "unavailable"`, `retrievalStatus === "blocked"`, `reason === "unauthorized"` 로 반환하고, 가능한 경우 `onlineStock.referenceOnly === true` 인 온라인 재고 참고값을 함께 확인합니다.
|
||||
- 매장 픽업 재고가 차단되면 `pickupStock.status === "unavailable"`, `retrievalStatus === "blocked"`, `reason === "unauthorized"` 로 반환하고, 공식 픽업 가능 매장 목록(`selPkupStr`) 으로 그 매장의 **픽업 가능 여부** 만이라도 `pickupEligibility` 로 회수합니다. 수량은 여전히 알 수 없습니다.
|
||||
- 매장 픽업 재고의 `status` 는 조회 결과 범주입니다. 실제 재고 여부는 `inStock` 또는 `inventoryStatus` (`"in_stock"`, `"out_of_stock"`, `"unknown"`) 를 기준으로 판단합니다.
|
||||
- 가능한 경우 `onlineStock.referenceOnly === true` 인 온라인 재고 참고값을 함께 확인할 수 있지만, 매장 재고로 단정해서는 안 됩니다.
|
||||
- 공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로 응답해야 합니다.
|
||||
|
||||
## 사용 예시
|
||||
|
|
@ -92,6 +93,27 @@ main().catch((error) => {
|
|||
}
|
||||
```
|
||||
|
||||
2026-05-08 부터는 매장 픽업 재고가 차단되면 공식 픽업 가능 매장 목록 표면(`selPkupStr`)을 추가로 호출해 그 매장이 해당 상품의 픽업 가능 매장에 들어 있는지 여부만이라도 회수합니다. 수량은 여전히 알 수 없지만, "그 매장에서 이 상품을 픽업으로 살 수 있는지" 는 답할 수 있게 됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"pickupEligibility": {
|
||||
"pdNo": "1049275",
|
||||
"strCd": "10224",
|
||||
"pickupEligible": true,
|
||||
"eligibleStoreCount": 1,
|
||||
"matchedStore": {
|
||||
"strCd": "10224",
|
||||
"name": "강남역2호점",
|
||||
"pickupAvailable": true,
|
||||
"openTime": "10:00",
|
||||
"closeTime": "22:00"
|
||||
},
|
||||
"retrievalStatus": "resolved"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 공개 API
|
||||
|
||||
- `searchStores(query, options?)`
|
||||
|
|
@ -102,6 +124,12 @@ main().catch((error) => {
|
|||
- 성공한 조회는 `status: "available"`, `retrievalStatus: "resolved"` 를 포함합니다. 여기서 `status` 는 조회 성공 범주이며 상품 재고 여부가 아닙니다.
|
||||
- 실제 재고 여부는 `inStock` 또는 `inventoryStatus` 로 확인합니다. 수량이 0이면 `status: "available"` 이면서 `inventoryStatus: "out_of_stock"` 일 수 있습니다.
|
||||
- 다이소몰이 매장 픽업 재고를 `401`/`403` 또는 `{ "success": false, "message": "Unauthorized" }` 로 차단하면 `status: "unavailable"`, `retrievalStatus: "blocked"`, `inventoryStatus: "unknown"` 결과를 반환합니다.
|
||||
- `getStorePickupEligibility({ pdNo, strCd, storeName?, keyword?, pageSize? }, options?)`
|
||||
- 공식 `POST /api/ms/msg/selPkupStr` 표면을 호출해 해당 상품의 픽업 가능 매장 목록을 받아 `pickupEligible` 여부를 판정합니다.
|
||||
- `storeName` 이 주어지면 매장명에서 `N호점` 같은 접미사를 제거해 `keyword` 로 자동 변환합니다. `keyword` 를 직접 넘기면 그대로 사용합니다. `strCd` 조회에서 `storeName`/`keyword` 가 없거나 첫 페이지가 전체 결과를 다 덮지 못하면 확정 `false` 대신 `pickupEligible: null`, `retrievalStatus: "insufficient_coverage"` 를 반환합니다.
|
||||
- 응답은 `pickupEligible`(`true`/`false`/`null`), `eligibleStoreCount`, `eligibleStores`, `matchedStore`, `searchedKeyword`, `pageSize`, `totalCount`, `retrievalStatus`, `raw` 를 포함합니다.
|
||||
- 정확한 수량은 제공되지 않습니다. 수량 확인은 `selStrPkupStck` 를 통해야 하며 차단 시에는 확인 불가입니다.
|
||||
- `getOnlineStock({ pdNo, onldPdNo? }, options?)`
|
||||
- 반환값은 `referenceOnly: true` 를 포함합니다. 온라인 재고는 다이소몰 온라인몰 재고 참고값이며 특정 매장의 픽업/진열 재고가 아닙니다.
|
||||
- `lookupStoreProductAvailability({ storeQuery, productQuery, ...options })`
|
||||
- `lookupStoreProductAvailability({ storeQuery, productQuery, includePickupEligibility?, ...options })`
|
||||
- `pickupStock.retrievalStatus === "blocked"` 일 때만 `selPkupStr` 폴백을 호출해 `pickupEligibility` 를 채웁니다. `includePickupEligibility: false` 로 끌 수 있습니다.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const {
|
|||
BASE_SEARCH_URL,
|
||||
buildSearchGoodsParams,
|
||||
normalizeOnlineStockResponse,
|
||||
normalizePickupEligibilityResponse,
|
||||
normalizeProductIdentifier,
|
||||
normalizeSearchGoodsResponse,
|
||||
normalizeStorePickupStockResponse,
|
||||
|
|
@ -153,6 +154,73 @@ async function getOnlineStock(request, options = {}) {
|
|||
return normalizeOnlineStockResponse(payload, normalizedRequest)
|
||||
}
|
||||
|
||||
function buildPickupEligibilityKeyword(value) {
|
||||
return String(value || "")
|
||||
.replace(/\d+\s*호점\s*$/u, "")
|
||||
.replace(/[(].*?[)]/gu, " ")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
async function getStorePickupEligibility(request, options = {}) {
|
||||
const pdNo = String(request.pdNo || "").trim()
|
||||
const strCd = String(request.strCd || "").trim()
|
||||
const explicitKeyword =
|
||||
typeof request.keyword === "string" && request.keyword.trim() ? request.keyword.trim() : null
|
||||
const derivedKeyword = explicitKeyword || buildPickupEligibilityKeyword(request.storeName)
|
||||
const pageSize = Number(request.pageSize || 50)
|
||||
|
||||
if (!pdNo) {
|
||||
throw new Error("pdNo is required.")
|
||||
}
|
||||
|
||||
if (strCd && !derivedKeyword) {
|
||||
return {
|
||||
pdNo,
|
||||
strCd,
|
||||
pickupEligible: null,
|
||||
eligibleStoreCount: null,
|
||||
eligibleStores: [],
|
||||
matchedStore: null,
|
||||
searchedKeyword: "",
|
||||
pageSize,
|
||||
totalCount: null,
|
||||
retrievalStatus: "insufficient_coverage",
|
||||
reason: "missing_search_keyword",
|
||||
raw: null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await requestJson(`${BASE_API_URL}/ms/msg/selPkupStr`, {
|
||||
...options,
|
||||
method: "POST",
|
||||
body: {
|
||||
pdNo,
|
||||
keyword: derivedKeyword || "",
|
||||
currentPage: 1,
|
||||
pageSize
|
||||
}
|
||||
})
|
||||
|
||||
return normalizePickupEligibilityResponse(payload, {
|
||||
pdNo,
|
||||
strCd,
|
||||
keyword: derivedKeyword || "",
|
||||
pageSize
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof DaisoRequestError) {
|
||||
return normalizePickupEligibilityResponse(
|
||||
error.payload || { success: false, message: `HTTP ${error.status}` },
|
||||
{ pdNo, strCd, keyword: derivedKeyword || "", pageSize }
|
||||
)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupStoreProductAvailability(options = {}) {
|
||||
const storeQuery = String(options.storeQuery || "").trim()
|
||||
const productQuery = String(options.productQuery || "").trim()
|
||||
|
|
@ -180,9 +248,7 @@ async function lookupStoreProductAvailability(options = {}) {
|
|||
|
||||
const selectedStore = storeResult.items[0]
|
||||
const selectedProduct = selectPickupPreferredProduct(productResult.items)
|
||||
const [storeDetailPayload, pickupStock, onlineStock] = await Promise.all([
|
||||
getStoreDetail(selectedStore.strCd, options),
|
||||
getStorePickupStock({ pdNo: selectedProduct.pdNo, strCd: selectedStore.strCd }, options),
|
||||
const onlineStockPromise =
|
||||
options.includeOnlineStock === false
|
||||
? Promise.resolve(null)
|
||||
: getOnlineStock(
|
||||
|
|
@ -191,9 +257,30 @@ async function lookupStoreProductAvailability(options = {}) {
|
|||
onldPdNo: selectedProduct.onldPdNo
|
||||
},
|
||||
options
|
||||
)
|
||||
).catch(() => null)
|
||||
const [storeDetailPayload, pickupStock] = await Promise.all([
|
||||
getStoreDetail(selectedStore.strCd, options),
|
||||
getStorePickupStock({ pdNo: selectedProduct.pdNo, strCd: selectedStore.strCd }, options)
|
||||
])
|
||||
|
||||
let pickupEligibility = null
|
||||
|
||||
if (
|
||||
options.includePickupEligibility !== false &&
|
||||
pickupStock &&
|
||||
pickupStock.retrievalStatus === "blocked"
|
||||
) {
|
||||
pickupEligibility = await getStorePickupEligibility(
|
||||
{
|
||||
pdNo: selectedProduct.pdNo,
|
||||
strCd: selectedStore.strCd,
|
||||
storeName: selectedStore.name
|
||||
},
|
||||
options
|
||||
)
|
||||
}
|
||||
const onlineStock = await onlineStockPromise
|
||||
|
||||
return {
|
||||
storeQuery,
|
||||
productQuery,
|
||||
|
|
@ -203,6 +290,7 @@ async function lookupStoreProductAvailability(options = {}) {
|
|||
storeDetail: storeDetailPayload.data || null,
|
||||
selectedProduct,
|
||||
pickupStock,
|
||||
pickupEligibility,
|
||||
onlineStock
|
||||
}
|
||||
}
|
||||
|
|
@ -210,6 +298,7 @@ async function lookupStoreProductAvailability(options = {}) {
|
|||
module.exports = {
|
||||
getOnlineStock,
|
||||
getStoreDetail,
|
||||
getStorePickupEligibility,
|
||||
getStorePickupStock,
|
||||
lookupStoreProductAvailability,
|
||||
searchProducts,
|
||||
|
|
|
|||
|
|
@ -386,6 +386,120 @@ function normalizeOnlineStockResponse(payload, request) {
|
|||
}
|
||||
}
|
||||
|
||||
function normalizePickupEligibilityStore(item) {
|
||||
return {
|
||||
strCd: String(item.strCd || ""),
|
||||
name: item.strNm || "",
|
||||
address: [item.strAddr, item.strDtlAddr].filter(Boolean).join(" "),
|
||||
phone: item.strTno || null,
|
||||
pickupAvailable: item.pkupYn === "Y",
|
||||
openTime: formatStoreTime(item.opngTime),
|
||||
closeTime: formatStoreTime(item.clsngTime),
|
||||
distanceKm: normalizeDistanceKm(item.km),
|
||||
latitude: toNumberOrNull(item.strLttd),
|
||||
longitude: toNumberOrNull(item.strLitd)
|
||||
}
|
||||
}
|
||||
|
||||
function firstNumericValue(...values) {
|
||||
for (const value of values) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
continue
|
||||
}
|
||||
|
||||
const numericValue = Number(value)
|
||||
|
||||
if (Number.isFinite(numericValue)) {
|
||||
return numericValue
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizePickupEligibilityResponse(payload, request) {
|
||||
const requestStrCd = String(request.strCd || "")
|
||||
const requestPdNo = String(request.pdNo || "")
|
||||
const searchedKeyword = String(request.keyword || "").trim()
|
||||
const requestedPageSize = firstNumericValue(request.pageSize)
|
||||
|
||||
if (!payload || typeof payload !== "object" || payload.success === false) {
|
||||
return {
|
||||
pdNo: requestPdNo,
|
||||
strCd: requestStrCd,
|
||||
pickupEligible: null,
|
||||
eligibleStoreCount: null,
|
||||
eligibleStores: [],
|
||||
matchedStore: null,
|
||||
searchedKeyword,
|
||||
pageSize: requestedPageSize,
|
||||
totalCount: null,
|
||||
retrievalStatus: "blocked",
|
||||
reason: "upstream_error",
|
||||
raw: payload || null
|
||||
}
|
||||
}
|
||||
|
||||
const rawItems = Array.isArray(payload.data) ? payload.data : []
|
||||
const eligibleStores = rawItems
|
||||
.filter((item) => item && item.strCd)
|
||||
.map(normalizePickupEligibilityStore)
|
||||
const matchedStore = requestStrCd
|
||||
? eligibleStores.find((store) => store.strCd === requestStrCd) || null
|
||||
: null
|
||||
const totalCount = firstNumericValue(
|
||||
payload.totalCnt,
|
||||
payload.totalCount,
|
||||
payload.extraData && payload.extraData.totalCnt,
|
||||
payload.extraData && payload.extraData.totalCount,
|
||||
rawItems[0] && rawItems[0].totalCnt
|
||||
)
|
||||
const currentPageCount = firstNumericValue(
|
||||
payload.currentPageCnt,
|
||||
payload.currentPageCount,
|
||||
payload.extraData && payload.extraData.currentPageCnt,
|
||||
payload.extraData && payload.extraData.currentPageCount,
|
||||
rawItems[0] && rawItems[0].currentPageCnt,
|
||||
rawItems.length
|
||||
)
|
||||
const pageSize = requestedPageSize || currentPageCount
|
||||
const hasMoreUnsearchedRows =
|
||||
Boolean(requestStrCd) &&
|
||||
!matchedStore &&
|
||||
(totalCount === null ? Boolean(pageSize && rawItems.length >= pageSize) : totalCount > rawItems.length)
|
||||
|
||||
if (hasMoreUnsearchedRows) {
|
||||
return {
|
||||
pdNo: requestPdNo,
|
||||
strCd: requestStrCd,
|
||||
pickupEligible: null,
|
||||
eligibleStoreCount: eligibleStores.length,
|
||||
eligibleStores,
|
||||
matchedStore,
|
||||
searchedKeyword,
|
||||
pageSize,
|
||||
totalCount,
|
||||
retrievalStatus: "insufficient_coverage",
|
||||
reason: "search_page_not_exhausted",
|
||||
raw: payload
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pdNo: requestPdNo,
|
||||
strCd: requestStrCd,
|
||||
pickupEligible: requestStrCd ? Boolean(matchedStore && matchedStore.pickupAvailable) : null,
|
||||
eligibleStoreCount: eligibleStores.length,
|
||||
eligibleStores,
|
||||
matchedStore,
|
||||
searchedKeyword,
|
||||
pageSize,
|
||||
totalCount,
|
||||
retrievalStatus: "resolved",
|
||||
raw: payload
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BASE_API_URL,
|
||||
BASE_SEARCH_URL,
|
||||
|
|
@ -393,6 +507,8 @@ module.exports = {
|
|||
STORE_EMPTY_RESULT_ERROR,
|
||||
buildSearchGoodsParams,
|
||||
normalizeOnlineStockResponse,
|
||||
normalizePickupEligibilityResponse,
|
||||
normalizePickupEligibilityStore,
|
||||
normalizeProductIdentifier,
|
||||
normalizeProductItem,
|
||||
normalizeSearchGoodsResponse,
|
||||
|
|
|
|||
58
packages/daiso-product-search/test/fixtures/store-pickup-eligibility.json
vendored
Normal file
58
packages/daiso-product-search/test/fixtures/store-pickup-eligibility.json
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"message": null,
|
||||
"data": [
|
||||
{
|
||||
"rgpsId": null,
|
||||
"regDttm": null,
|
||||
"mdpsId": null,
|
||||
"modDttm": null,
|
||||
"totalCnt": 1,
|
||||
"dataAllTotal": 0,
|
||||
"currentPageCnt": 1,
|
||||
"rnum": 1,
|
||||
"status": null,
|
||||
"frRgerId": null,
|
||||
"frRgDtm": null,
|
||||
"lsUpdrId": null,
|
||||
"lsUpdtDtm": null,
|
||||
"strCd": "10224",
|
||||
"strNm": "강남역2호점",
|
||||
"strZip": "06134",
|
||||
"strAddr": "서울특별시 강남구 테헤란로 109 (역삼동)",
|
||||
"strDtlAddr": null,
|
||||
"strTno": "1522-4400",
|
||||
"brchChgrCrryTno": null,
|
||||
"parkYn": "N",
|
||||
"nmtkYn": "N",
|
||||
"phcYn": "N",
|
||||
"usimYn": "Y",
|
||||
"ovrseaUsimYn": "N",
|
||||
"hbrdUsimYn": "N",
|
||||
"phstkYn": "N",
|
||||
"opngTime": "10:00",
|
||||
"clsngTime": "22:00",
|
||||
"pkupYn": "Y",
|
||||
"bassPkupStrYn": "N",
|
||||
"pntAcmYn": "Y",
|
||||
"pntUseYn": "Y",
|
||||
"elecRctwYn": "Y",
|
||||
"strLitd": 127.028991200366,
|
||||
"strLttd": 37.4988240219594,
|
||||
"km": "13181.1",
|
||||
"useYn": "Y",
|
||||
"directYn": "Y",
|
||||
"nocashYn": "N",
|
||||
"hldyDt": null,
|
||||
"sccardYn": "N",
|
||||
"sccashYn": "N",
|
||||
"elvtYn": "N",
|
||||
"entrRampYn": "Y",
|
||||
"taxfYn": "Y",
|
||||
"hflpYn": "N"
|
||||
}
|
||||
],
|
||||
"extraData": {},
|
||||
"extraString": null,
|
||||
"returnCode": null,
|
||||
"success": true
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ const path = require("node:path")
|
|||
const {
|
||||
getOnlineStock,
|
||||
getStoreDetail,
|
||||
getStorePickupEligibility,
|
||||
getStorePickupStock,
|
||||
lookupStoreProductAvailability,
|
||||
searchProducts,
|
||||
|
|
@ -13,6 +14,7 @@ const {
|
|||
} = require("../src/index")
|
||||
const {
|
||||
buildSearchGoodsParams,
|
||||
normalizePickupEligibilityResponse,
|
||||
normalizeSearchGoodsResponse,
|
||||
normalizeStorePickupStockResponse,
|
||||
normalizeStoreSearchResponse
|
||||
|
|
@ -24,6 +26,9 @@ const searchGoodsPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "se
|
|||
const storeDetailPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-detail.json"), "utf8"))
|
||||
const storePickupStockPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-pickup-stock.json"), "utf8"))
|
||||
const onlineStockPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "online-stock.json"), "utf8"))
|
||||
const storePickupEligibilityPayload = JSON.parse(
|
||||
fs.readFileSync(path.join(fixturesDir, "store-pickup-eligibility.json"), "utf8")
|
||||
)
|
||||
const liveSearchGoodsPayload = {
|
||||
resultSet: {
|
||||
result: [
|
||||
|
|
@ -339,7 +344,7 @@ test("lookupStoreProductAvailability keeps online-stock fallback when Daiso pick
|
|||
const originalFetch = global.fetch
|
||||
|
||||
global.fetch = async (url) => {
|
||||
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo")) {
|
||||
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
|
||||
return makeResponse(storeSearchPayload)
|
||||
}
|
||||
|
||||
|
|
@ -355,6 +360,10 @@ test("lookupStoreProductAvailability keeps online-stock fallback when Daiso pick
|
|||
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/ms/msg/selPkupStr")) {
|
||||
return makeResponse(storePickupEligibilityPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/pdo/selOnlStck")) {
|
||||
return makeResponse(onlineStockPayload)
|
||||
}
|
||||
|
|
@ -374,6 +383,315 @@ test("lookupStoreProductAvailability keeps online-stock fallback when Daiso pick
|
|||
assert.equal(availability.pickupStock.reason, "unauthorized")
|
||||
assert.equal(availability.onlineStock.quantity, 13047)
|
||||
assert.equal(availability.onlineStock.referenceOnly, true)
|
||||
assert.notEqual(availability.pickupEligibility, null)
|
||||
assert.equal(availability.pickupEligibility.pickupEligible, true)
|
||||
assert.equal(availability.pickupEligibility.matchedStore.strCd, "10224")
|
||||
assert.equal(availability.pickupEligibility.retrievalStatus, "resolved")
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("lookupStoreProductAvailability still resolves pickup eligibility when online stock fails", async () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
global.fetch = async (url) => {
|
||||
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
|
||||
return makeResponse(storeSearchPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/ssn/search/SearchGoods")) {
|
||||
return makeResponse(searchGoodsPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
|
||||
return makeResponse(storeDetailPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
|
||||
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/ms/msg/selPkupStr")) {
|
||||
return makeResponse(storePickupEligibilityPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/pdo/selOnlStck")) {
|
||||
return makeResponse({ success: false, message: "Internal Server Error" }, { status: 500 })
|
||||
}
|
||||
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const availability = await lookupStoreProductAvailability({
|
||||
storeQuery: "강남역2호점",
|
||||
productQuery: "VT 리들샷 100"
|
||||
})
|
||||
|
||||
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
|
||||
assert.equal(availability.pickupEligibility.pickupEligible, true)
|
||||
assert.equal(availability.pickupEligibility.matchedStore.strCd, "10224")
|
||||
assert.equal(availability.onlineStock, null)
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("normalizePickupEligibilityResponse marks selected store as eligible when present in list", () => {
|
||||
const eligibility = normalizePickupEligibilityResponse(storePickupEligibilityPayload, {
|
||||
pdNo: "1049275",
|
||||
strCd: "10224"
|
||||
})
|
||||
|
||||
assert.equal(eligibility.pickupEligible, true)
|
||||
assert.equal(eligibility.eligibleStoreCount, 1)
|
||||
assert.equal(eligibility.matchedStore.strCd, "10224")
|
||||
assert.equal(eligibility.matchedStore.name, "강남역2호점")
|
||||
assert.equal(eligibility.matchedStore.pickupAvailable, true)
|
||||
assert.equal(eligibility.matchedStore.openTime, "10:00")
|
||||
assert.equal(eligibility.retrievalStatus, "resolved")
|
||||
})
|
||||
|
||||
test("normalizePickupEligibilityResponse marks selected store as NOT eligible when absent from list", () => {
|
||||
const eligibility = normalizePickupEligibilityResponse(storePickupEligibilityPayload, {
|
||||
pdNo: "1049275",
|
||||
strCd: "99999"
|
||||
})
|
||||
|
||||
assert.equal(eligibility.pickupEligible, false)
|
||||
assert.equal(eligibility.eligibleStoreCount, 1)
|
||||
assert.equal(eligibility.matchedStore, null)
|
||||
assert.equal(eligibility.retrievalStatus, "resolved")
|
||||
})
|
||||
|
||||
test("normalizePickupEligibilityResponse requires pickupAvailable for positive eligibility", () => {
|
||||
const payload = {
|
||||
...storePickupEligibilityPayload,
|
||||
data: storePickupEligibilityPayload.data.map((item) => ({ ...item, pkupYn: "N" }))
|
||||
}
|
||||
|
||||
const eligibility = normalizePickupEligibilityResponse(payload, {
|
||||
pdNo: "1049275",
|
||||
strCd: "10224",
|
||||
keyword: "강남역",
|
||||
pageSize: 50
|
||||
})
|
||||
|
||||
assert.equal(eligibility.pickupEligible, false)
|
||||
assert.equal(eligibility.matchedStore.strCd, "10224")
|
||||
assert.equal(eligibility.matchedStore.pickupAvailable, false)
|
||||
assert.equal(eligibility.retrievalStatus, "resolved")
|
||||
})
|
||||
|
||||
test("normalizePickupEligibilityResponse avoids a definitive miss when the first page may be incomplete", () => {
|
||||
const payload = {
|
||||
...storePickupEligibilityPayload,
|
||||
data: [
|
||||
{
|
||||
...storePickupEligibilityPayload.data[0],
|
||||
totalCnt: 2,
|
||||
currentPageCnt: 1,
|
||||
strCd: "10000",
|
||||
strNm: "서울테스트점"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const eligibility = normalizePickupEligibilityResponse(payload, {
|
||||
pdNo: "1049275",
|
||||
strCd: "10224",
|
||||
keyword: "서울",
|
||||
pageSize: 1
|
||||
})
|
||||
|
||||
assert.equal(eligibility.pickupEligible, null)
|
||||
assert.equal(eligibility.reason, "search_page_not_exhausted")
|
||||
assert.equal(eligibility.retrievalStatus, "insufficient_coverage")
|
||||
assert.equal(eligibility.searchedKeyword, "서울")
|
||||
assert.equal(eligibility.totalCount, 2)
|
||||
})
|
||||
|
||||
test("normalizePickupEligibilityResponse handles upstream failure as blocked retrieval", () => {
|
||||
const eligibility = normalizePickupEligibilityResponse(
|
||||
{ success: false, message: "Upstream error" },
|
||||
{ pdNo: "1049275", strCd: "10224" }
|
||||
)
|
||||
|
||||
assert.equal(eligibility.pickupEligible, null)
|
||||
assert.equal(eligibility.eligibleStoreCount, null)
|
||||
assert.equal(eligibility.eligibleStores.length, 0)
|
||||
assert.equal(eligibility.matchedStore, null)
|
||||
assert.equal(eligibility.retrievalStatus, "blocked")
|
||||
assert.equal(eligibility.reason, "upstream_error")
|
||||
})
|
||||
|
||||
test("getStorePickupEligibility posts pdNo and a derived store keyword to selPkupStr", async () => {
|
||||
const originalFetch = global.fetch
|
||||
let capturedBody = null
|
||||
let capturedUrl = null
|
||||
|
||||
global.fetch = async (url, init = {}) => {
|
||||
capturedUrl = String(url)
|
||||
capturedBody = JSON.parse(init.body)
|
||||
return makeResponse(storePickupEligibilityPayload)
|
||||
}
|
||||
|
||||
try {
|
||||
const eligibility = await getStorePickupEligibility({
|
||||
pdNo: "1049275",
|
||||
strCd: "10224",
|
||||
storeName: "강남역2호점"
|
||||
})
|
||||
|
||||
assert.match(capturedUrl, /\/api\/ms\/msg\/selPkupStr$/)
|
||||
assert.equal(capturedBody.pdNo, "1049275")
|
||||
assert.equal(capturedBody.keyword, "강남역")
|
||||
assert.equal(capturedBody.currentPage, 1)
|
||||
assert.equal(typeof capturedBody.pageSize, "number")
|
||||
assert.equal(eligibility.pickupEligible, true)
|
||||
assert.equal(eligibility.matchedStore.strCd, "10224")
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("getStorePickupEligibility does not emit a definitive false without a store keyword", async () => {
|
||||
const originalFetch = global.fetch
|
||||
let fetchCalled = false
|
||||
|
||||
global.fetch = async () => {
|
||||
fetchCalled = true
|
||||
return makeResponse({ ...storePickupEligibilityPayload, data: [] })
|
||||
}
|
||||
|
||||
try {
|
||||
const eligibility = await getStorePickupEligibility({
|
||||
pdNo: "1049275",
|
||||
strCd: "10224"
|
||||
})
|
||||
|
||||
assert.equal(fetchCalled, false)
|
||||
assert.equal(eligibility.pickupEligible, null)
|
||||
assert.equal(eligibility.retrievalStatus, "insufficient_coverage")
|
||||
assert.equal(eligibility.reason, "missing_search_keyword")
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("getStorePickupEligibility surfaces upstream HTTP errors as blocked retrieval", async () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
global.fetch = async () =>
|
||||
makeResponse({ success: false, message: "Internal Server Error" }, { status: 500 })
|
||||
|
||||
try {
|
||||
const eligibility = await getStorePickupEligibility({
|
||||
pdNo: "1049275",
|
||||
strCd: "10224",
|
||||
storeName: "강남역2호점"
|
||||
})
|
||||
|
||||
assert.equal(eligibility.pickupEligible, null)
|
||||
assert.equal(eligibility.matchedStore, null)
|
||||
assert.equal(eligibility.retrievalStatus, "blocked")
|
||||
assert.equal(eligibility.reason, "upstream_error")
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("lookupStoreProductAvailability skips pickup eligibility lookup when pickup stock resolves", async () => {
|
||||
const originalFetch = global.fetch
|
||||
let eligibilityCalled = false
|
||||
|
||||
global.fetch = async (url) => {
|
||||
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
|
||||
return makeResponse(storeSearchPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/ssn/search/SearchGoods")) {
|
||||
return makeResponse(searchGoodsPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
|
||||
return makeResponse(storeDetailPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
|
||||
return makeResponse(storePickupStockPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/ms/msg/selPkupStr")) {
|
||||
eligibilityCalled = true
|
||||
return makeResponse(storePickupEligibilityPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/pdo/selOnlStck")) {
|
||||
return makeResponse(onlineStockPayload)
|
||||
}
|
||||
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const availability = await lookupStoreProductAvailability({
|
||||
storeQuery: "강남역2호점",
|
||||
productQuery: "VT 리들샷 100"
|
||||
})
|
||||
|
||||
assert.equal(availability.pickupStock.retrievalStatus, "resolved")
|
||||
assert.equal(eligibilityCalled, false)
|
||||
assert.equal(availability.pickupEligibility, null)
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("lookupStoreProductAvailability respects includePickupEligibility=false even when pickup stock is blocked", async () => {
|
||||
const originalFetch = global.fetch
|
||||
let eligibilityCalled = false
|
||||
|
||||
global.fetch = async (url) => {
|
||||
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
|
||||
return makeResponse(storeSearchPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/ssn/search/SearchGoods")) {
|
||||
return makeResponse(searchGoodsPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
|
||||
return makeResponse(storeDetailPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
|
||||
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/ms/msg/selPkupStr")) {
|
||||
eligibilityCalled = true
|
||||
return makeResponse(storePickupEligibilityPayload)
|
||||
}
|
||||
|
||||
if (String(url).includes("/api/pdo/selOnlStck")) {
|
||||
return makeResponse(onlineStockPayload)
|
||||
}
|
||||
|
||||
return new Response("not found", { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const availability = await lookupStoreProductAvailability({
|
||||
storeQuery: "강남역2호점",
|
||||
productQuery: "VT 리들샷 100",
|
||||
includePickupEligibility: false
|
||||
})
|
||||
|
||||
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
|
||||
assert.equal(eligibilityCalled, false)
|
||||
assert.equal(availability.pickupEligibility, null)
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue