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