diff --git a/apps/web/src/components/inspect/DeepInspectWorkspace.tsx b/apps/web/src/components/inspect/DeepInspectWorkspace.tsx index e6617ff0..6ac6f976 100644 --- a/apps/web/src/components/inspect/DeepInspectWorkspace.tsx +++ b/apps/web/src/components/inspect/DeepInspectWorkspace.tsx @@ -56,6 +56,7 @@ import { implausiblyLowOfferIds, plausibleOffers, } from '@repo/shared-data/rules'; +import { isOfferUnavailable } from '@/lib/product-market-stats'; import { productImageUrl } from '@/lib/rate-domain-core'; const INSPECT_CONTROL_CHARS_RE = /\p{Cc}+/gu; @@ -184,8 +185,13 @@ export default function DeepInspectWorkspace({ }; const master = result?.masterProduct ?? null; + // A sold-out listing is never the fallback match (DOS/Rate#1177). const matchedOffer = - result?.matchedOffer ?? plausibleOffers(result?.allOffers ?? [])[0] ?? null; + result?.matchedOffer ?? + plausibleOffers( + (result?.allOffers ?? []).filter((o) => !isOfferUnavailable(o)) + )[0] ?? + null; const discount = result?.discountAnalysis ?? null; // Judged as the product page's offers table judges the row (#797). const implausibleOfferIds = implausiblyLowOfferIds(result?.allOffers ?? []); diff --git a/apps/web/src/lib/rate-api.ts b/apps/web/src/lib/rate-api.ts index f87ef989..308fd275 100644 --- a/apps/web/src/lib/rate-api.ts +++ b/apps/web/src/lib/rate-api.ts @@ -3914,10 +3914,11 @@ export async function inspectProductOrShopUrl( getMasterProductPriceHistory(masterProduct.id, offers), getMasterProductReviewSummary(masterProduct.id, reviews), ]); - // No bait-price listing as the fallback match (DOS/Rate#797). + // No bait-price or sold-out listing as the fallback match + // (DOS/Rate#797, #1177). const matchedOffer = offers.find((o) => o.id === matchedProd.id) || - plausibleOffers(offers)[0] || + plausibleOffers(offers.filter((o) => !isOfferUnavailable(o)))[0] || null; const discountAnalysis = matchedOffer ? priceHistory.byOffer[matchedOffer.id] || null @@ -4023,8 +4024,11 @@ export async function inspectProductOrShopUrl( ); if (byShopMerchant) return byShopMerchant; } - // The cheapest plausible offer, never a bait-price listing (DOS/Rate#797). - return plausibleOffers(offers)[0] || null; + // The cheapest plausible in-stock offer, never a bait-price or sold-out + // listing (DOS/Rate#797, #1177). + return ( + plausibleOffers(offers.filter((o) => !isOfferUnavailable(o)))[0] || null + ); }; // 3. Internal slug or exact master product slug match (including slugified URL keyword) diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index f01f633e..1d600c03 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -13674,6 +13674,210 @@ async function runCoreArchitectureSuite() { ); } + // ------------------------------------------------------------------------- + // 126l. Extension verdict, cheaper-elsewhere and fallback match skip + // sold-out listings (#1177) + // ------------------------------------------------------------------------- + { + const { evaluateExtensionPriceVerdict, lookupExtensionProduct } = + await import('@/server/rate/extension'); + + const offer1177 = ( + id: string, + price: number, + availability: string | null + ) => + ({ + id, + platform: 'shopee', + url: `https://shopee.vn/i.1177.${id}`, + price_current: price, + price_original: null, + availability, + merchant: { + id: `m-${id}`, + name: `Shop ${id}`, + risk_score: 5, + is_official: true, + }, + }) as never; + const offers1177 = [ + offer1177('fpt-sold', 2_694_000, 'out_of_stock'), + offer1177('fpt-gone', 3_032_000, 'discontinued'), + offer1177('tgdd', 3_140_000, 'in_stock'), + offer1177('cps', 3_690_000, null), + ]; + const verdict1177 = evaluateExtensionPriceVerdict({ + isAccessory: false, + accessoryReason: null, + isAmbiguousVariant: false, + observedPrice: 3_140_000, + observedOriginalPrice: null, + offers: offers1177, + observedOfferId: 'tgdd', + priceHistory: null, + }); + assert.equal(verdict1177.marketMinPrice, 3_140_000); + assert.equal(verdict1177.marketMaxPrice, 3_690_000); + assert.equal(verdict1177.cheaperOfferCount, 0); + assert.equal(verdict1177.maxSavingsAmount, 0); + // A sold-out listing's own page is judged against the in-stock market. + const ownSold1177 = evaluateExtensionPriceVerdict({ + isAccessory: false, + accessoryReason: null, + isAmbiguousVariant: false, + observedPrice: 3_032_000, + observedOriginalPrice: null, + offers: offers1177, + observedOfferId: 'fpt-gone', + priceHistory: null, + }); + assert.equal(ownSold1177.marketMinPrice, 3_140_000); + assert.equal(ownSold1177.cheaperOfferCount, 0); + assert.notEqual(ownSold1177.badgeLabel, 'Giá thấp bất thường'); + // From the dearest in-stock page, only the in-stock store is cheaper. + const soldPage1177 = evaluateExtensionPriceVerdict({ + isAccessory: false, + accessoryReason: null, + isAmbiguousVariant: false, + observedPrice: 3_690_000, + observedOriginalPrice: null, + offers: offers1177, + observedOfferId: 'cps', + priceHistory: null, + }); + assert.equal(soldPage1177.cheaperOfferCount, 1); + assert.equal(soldPage1177.maxSavingsAmount, 550_000); + + const origFetch1177 = globalThis.fetch; + const itemIds1177: Record = { + 'fpt-sold': 117711, + 'fpt-gone': 117712, + tgdd: 117713, + cps: 117714, + }; + const viewRow1177 = (id: string, price: number) => ({ + id, + cluster_id: 'master-1177', + merchant_id: `m-${id}`, + platform: 'shopee', + title: 'Samsung Galaxy Buds3 Pro', + price_current: price, + stock: 0, + url: `https://shopee.vn/product-i.1177.${itemIds1177[id]}`, + threat_risk_score: 5, + threat_source: 'dosafe', + dosafe_risk_score: 5, + review_risk_score: 5, + review_risk_breakdown: { total_reviews: 3 }, + }); + const json1177 = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + try { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url + ); + const firstPage = Number(url.searchParams.get('offset') ?? 0) === 0; + const select = url.searchParams.get('select') ?? ''; + if ( + url.pathname.endsWith('/products') && + url.searchParams.has('platform_item_id') + ) { + return json1177([ + { cluster_id: 'master-1177', platform_item_id: '1177_117714' }, + ]); + } + if ( + url.pathname.endsWith('/master_products') && + url.searchParams.get('id') === 'eq.master-1177' + ) { + return json1177({ + id: 'master-1177', + slug: 'samsung-galaxy-buds3-pro', + name: 'Samsung Galaxy Buds3 Pro', + brand: 'Samsung', + model: 'Galaxy Buds3 Pro', + images: [], + key_specs: {}, + specifications: {}, + min_price: 3_140_000, + max_price: 3_690_000, + category_id: 'cat-earbuds', + }); + } + if (url.pathname.endsWith('/v_products_with_merchant_trust')) { + return json1177( + firstPage + ? [ + viewRow1177('fpt-sold', 2_694_000), + viewRow1177('fpt-gone', 3_032_000), + viewRow1177('tgdd', 3_140_000), + viewRow1177('cps', 3_690_000), + ] + : [] + ); + } + if ( + url.pathname.endsWith('/products') && + select === 'id,availability' + ) { + return json1177( + firstPage + ? [ + { id: 'fpt-sold', availability: 'out_of_stock' }, + { id: 'fpt-gone', availability: 'discontinued' }, + { id: 'tgdd', availability: 'in_stock' }, + { id: 'cps', availability: 'in_stock' }, + ] + : [] + ); + } + return json1177([]); + }) as typeof fetch; + + // On the CellphoneS listing: the sold-out FPT listings are not cheaper. + const onCps1177 = await lookupExtensionProduct({ + platform: 'shopee', + externalId: '1177_117714', + mode: 'pill', + }); + assert.equal(onCps1177.masterProduct?.id, 'master-1177'); + assert.equal(onCps1177.matchedOffer?.id, 'cps'); + assert.deepEqual( + onCps1177.topCheaperOffers.map((o) => o.id), + ['tgdd'] + ); + assert.equal(onCps1177.priceAssessment.marketMinPrice, 3_140_000); + + // No listing matches: the fallback is the cheapest in-stock offer. + const unmatched1177 = await lookupExtensionProduct({ + platform: 'shopee', + externalId: '1177_117799', + mode: 'pill', + }); + assert.equal(unmatched1177.matchedOffer?.id, 'tgdd'); + assert.ok( + unmatched1177.topCheaperOffers.every( + (o) => o.id !== 'fpt-sold' && o.id !== 'fpt-gone' + ) + ); + } finally { + globalThis.fetch = origFetch1177; + } + + console.log( + ' ✅ [126l/135] Extension verdict, cheaper-elsewhere and fallback match skip sold-out listings (#1177) passed' + ); + } + // ------------------------------------------------------------------------- // 127. Rate Server Index & Extension Entity, Slug, and Category Bounds (#1029) // ------------------------------------------------------------------------- diff --git a/apps/web/src/server/rate/extension.ts b/apps/web/src/server/rate/extension.ts index 5186a6ec..0d9e0e1e 100644 --- a/apps/web/src/server/rate/extension.ts +++ b/apps/web/src/server/rate/extension.ts @@ -28,6 +28,7 @@ import { type MerchantSummary, type ReviewAnalysisSummary, } from '../../lib/rate-api'; +import { isOfferUnavailable } from '../../lib/product-market-stats'; import { computeProductStandingInCategory, type ProductLeaderboardBadgeInfo, @@ -606,7 +607,11 @@ export function evaluateExtensionPriceVerdict(params: { observedOfferId, priceHistory, } = safeParams; - const safeOffers = Array.isArray(offers) ? offers : []; + // Sold-out and discontinued listings keep their last price; they set no + // market reference and are no cheaper option (DOS/Rate#1177). + const safeOffers = (Array.isArray(offers) ? offers : []).filter( + (o) => !isOfferUnavailable(o) + ); if (isAccessory) { return { @@ -1223,15 +1228,17 @@ export async function lookupExtensionProduct( // Fetch offers, price history, and reviews for matched master product const offers = await getMasterProductOffers(matchedMaster.id); - // Without a URL match, fall back to the cheapest plausible offer, never a - // bait-price listing (DOS/Rate#797). + // Without a URL match, fall back to the cheapest plausible in-stock offer, + // never a bait-price or sold-out listing (DOS/Rate#797, #1177). + const inStockOffers = offers.filter((o) => !isOfferUnavailable(o)); const urlMatchedOffer = offers.find( (o) => (canonicalUrl && o.url.includes(canonicalUrl)) || doesOfferUrlMatchCandidateIds(o.url, candidateItemIds) ) || null; - const matchedOffer = urlMatchedOffer || plausibleOffers(offers)[0] || null; + const matchedOffer = + urlMatchedOffer || plausibleOffers(inStockOffers)[0] || null; if ( matchedOffer && @@ -1305,10 +1312,11 @@ export async function lookupExtensionProduct( : null; const isAmbiguous = matchStatus === 'ambiguous_variant'; - // The market reference and "cheaper elsewhere" leave bait-price listings - // out; the matched offer above still comes from every offer, so a shopper - // on a bait listing sees it judged against the real market (DOS/Rate#797). - const marketOffers = plausibleOffers(offers); + // The market reference and "cheaper elsewhere" leave bait-price and + // sold-out listings out; the URL-matched offer above still comes from every + // offer, so a shopper on such a listing sees it judged against the real + // market (DOS/Rate#797, #1177). + const marketOffers = plausibleOffers(inStockOffers); const priceAssessment = evaluateExtensionPriceVerdict({ isAccessory: false, accessoryReason: null,