From 5f23c6e5aa31d20661e1b5cee494eb02b7ceecd0 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Mon, 17 Aug 2026 23:49:24 +0400 Subject: [PATCH] feat: Phase 3 frontend - Offer/InventoryRecord core, mock-gateway backed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/offers module against docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT- CONTRACT.md §2-3, §7. OfferLocalGateway derives one Offer per existing AdminProduct (sellerId defaults to 'marketplace-owned' when absent - same convention AdminProduct.sellerId already documents) so the shape is real without touching the live admin Products domain. Scope note: this is the core swappable seam only (model + gateway + token), same judgment as Phase 1's pricing core - deferred is the actual Product/ Offer split UI (multi-seller product page, admin lookup-by-SKU screen), which is the single largest structural change in the whole programme per the delivery plan and needs its own dedicated pass against a real backend, not a bundled mock-data rewire of the working Products admin surface. Co-Authored-By: Claude Sonnet 5 --- src/app/core/offers/models/offer.model.ts | 30 ++++++++++ .../services/offer-gateway.interface.ts | 10 ++++ .../offers/services/offer-gateway.token.ts | 9 +++ .../offers/services/offer-local.gateway.ts | 58 +++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 src/app/core/offers/models/offer.model.ts create mode 100644 src/app/core/offers/services/offer-gateway.interface.ts create mode 100644 src/app/core/offers/services/offer-gateway.token.ts create mode 100644 src/app/core/offers/services/offer-local.gateway.ts diff --git a/src/app/core/offers/models/offer.model.ts b/src/app/core/offers/models/offer.model.ts new file mode 100644 index 0000000..7a819f6 --- /dev/null +++ b/src/app/core/offers/models/offer.model.ts @@ -0,0 +1,30 @@ +import { Money } from '../../pricing/models/money.model'; + +/** Per docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §2-3. */ +export interface Offer { + id: string; + marketplaceId: string; + sellerId: string; + variantId: string; + sellerSku: string; + price: Money; + stockPolicy: 'track' | 'no_track' | 'preorder'; + status: 'draft' | 'moderation' | 'published' | 'paused' | 'archived'; + publishedAt?: string; + executabilityChecked: boolean; +} + +export interface InventoryRecord { + offerId: string; + available: number; + reserved: number; + sold: number; + warehouse?: string; + source: 'manual' | 'feed_sync' | 'connector'; +} + +export interface OfferLookupQuery { + sku?: string; + sellerSku?: string; + externalId?: string; +} diff --git a/src/app/core/offers/services/offer-gateway.interface.ts b/src/app/core/offers/services/offer-gateway.interface.ts new file mode 100644 index 0000000..90bfe5a --- /dev/null +++ b/src/app/core/offers/services/offer-gateway.interface.ts @@ -0,0 +1,10 @@ +import { Observable } from 'rxjs'; +import { InventoryRecord, Offer, OfferLookupQuery } from '../models/offer.model'; + +/** Per docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7. */ +export interface OfferGateway { + loadOffers(sellerId?: string): Observable; + lookup(query: OfferLookupQuery): Observable; + loadInventory(offerId: string): Observable; + publish(offerId: string): Observable<{ ok: true } | { ok: false; errors: string[] }>; +} diff --git a/src/app/core/offers/services/offer-gateway.token.ts b/src/app/core/offers/services/offer-gateway.token.ts new file mode 100644 index 0000000..8cb5ae5 --- /dev/null +++ b/src/app/core/offers/services/offer-gateway.token.ts @@ -0,0 +1,9 @@ +import { InjectionToken, inject } from '@angular/core'; +import { OfferGateway } from './offer-gateway.interface'; +import { OfferLocalGateway } from './offer-local.gateway'; + +/** Swap point for docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7. */ +export const OFFER_GATEWAY = new InjectionToken('OFFER_GATEWAY', { + providedIn: 'root', + factory: () => inject(OfferLocalGateway), +}); diff --git a/src/app/core/offers/services/offer-local.gateway.ts b/src/app/core/offers/services/offer-local.gateway.ts new file mode 100644 index 0000000..9d92601 --- /dev/null +++ b/src/app/core/offers/services/offer-local.gateway.ts @@ -0,0 +1,58 @@ +import { Injectable, inject } from '@angular/core'; +import { Observable, map, of } from 'rxjs'; +import { InventoryRecord, Offer, OfferLookupQuery } from '../models/offer.model'; +import { OfferGateway } from './offer-gateway.interface'; +import { ADMIN_PRODUCTS_GATEWAY } from '../../../features/admin/products/services/admin-products-gateway.token'; +import { fromMajor } from '../../pricing/models/money.model'; + +/** + * Derives Offers from the existing (mock) Products gateway - one offer per + * product, sellerId defaulted to 'marketplace-owned' when absent, exactly + * matching AdminProduct.sellerId's existing "absent means marketplace-owned" + * convention. Real Offer/Product split (docs/backend/ + * PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md) replaces this once a real + * backend exists - swap OFFER_GATEWAY, no caller changes needed. + */ +@Injectable({ providedIn: 'root' }) +export class OfferLocalGateway implements OfferGateway { + private readonly productsGateway = inject(ADMIN_PRODUCTS_GATEWAY); + + loadOffers(sellerId?: string): Observable { + return this.productsGateway.loadProducts({ search: '', page: 1, pageSize: 100 } as any).pipe( + map((result: any) => (result.items ?? []) + .map((p: any): Offer => this.toOffer(p)) + .filter((o: Offer) => !sellerId || o.sellerId === sellerId)) + ); + } + + lookup(query: OfferLookupQuery): Observable { + return this.loadOffers().pipe( + map(offers => offers.filter(o => + (!query.sku || o.sellerSku === query.sku) && + (!query.sellerSku || o.sellerSku === query.sellerSku) + )) + ); + } + + loadInventory(offerId: string): Observable { + return of({ offerId, available: 42, reserved: 0, sold: 0, source: 'manual' }); + } + + publish(_offerId: string): Observable<{ ok: true } | { ok: false; errors: string[] }> { + return of({ ok: true }); + } + + private toOffer(product: any): Offer { + return { + id: `offer_${product.id}`, + marketplaceId: 'default', + sellerId: product.sellerId ?? 'marketplace-owned', + variantId: product.id, + sellerSku: product.sku, + price: fromMajor(product.price ?? 0, product.currency ?? 'RUB'), + stockPolicy: 'track', + status: product.visible ? 'published' : 'draft', + executabilityChecked: !!product.visible, + }; + } +}