feat: Phase 3 frontend - Offer/InventoryRecord core, mock-gateway backed
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 <noreply@anthropic.com>
This commit is contained in:
30
src/app/core/offers/models/offer.model.ts
Normal file
30
src/app/core/offers/models/offer.model.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
10
src/app/core/offers/services/offer-gateway.interface.ts
Normal file
10
src/app/core/offers/services/offer-gateway.interface.ts
Normal file
@@ -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<Offer[]>;
|
||||||
|
lookup(query: OfferLookupQuery): Observable<Offer[]>;
|
||||||
|
loadInventory(offerId: string): Observable<InventoryRecord | null>;
|
||||||
|
publish(offerId: string): Observable<{ ok: true } | { ok: false; errors: string[] }>;
|
||||||
|
}
|
||||||
9
src/app/core/offers/services/offer-gateway.token.ts
Normal file
9
src/app/core/offers/services/offer-gateway.token.ts
Normal file
@@ -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<OfferGateway>('OFFER_GATEWAY', {
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => inject(OfferLocalGateway),
|
||||||
|
});
|
||||||
58
src/app/core/offers/services/offer-local.gateway.ts
Normal file
58
src/app/core/offers/services/offer-local.gateway.ts
Normal file
@@ -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<Offer[]> {
|
||||||
|
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<Offer[]> {
|
||||||
|
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<InventoryRecord | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user