Compare commits
5 Commits
4b1168dbfa
...
079bf8c0f0
| Author | SHA1 | Date |
|---|---|---|
|
|
079bf8c0f0 | |
|
|
7020433e5a | |
|
|
2a0bf6fa90 | |
|
|
bb54dfcf59 | |
|
|
a6f888c672 |
|
|
@ -0,0 +1,102 @@
|
|||
# Workspace Development Rules: Kell Creations Platform
|
||||
|
||||
## 1. Core Operating Principles
|
||||
|
||||
1. **Vertical Slice Discipline:** Implement features in isolated, reviewable vertical slices on feature branches. Never push unverified, broad changes directly to `main`[cite: 3].
|
||||
2. **Inspection First:** Always inspect the codebase, verify existing tests, and review existing contracts before writing or modifying code[cite: 3].
|
||||
3. **Environment Isolation:** Maintain runtime environment modes (`fake`, `wp`, `square`, `multi`) specified exclusively via `--dart-define` keys[cite: 2, 3]. Never build hardcoded settings UIs or commit API keys/tokens to source control[cite: 3].
|
||||
4. **Strict Package Boundaries:**
|
||||
- Feature packages (`feature_*`) must remain decoupled[cite: 1, 3].
|
||||
- Cross-feature communication must use shared abstractions in `core`, `integrations`, or `data`[cite: 1, 3].
|
||||
- Platform-specific UI code must reside in `apps/kell_web` or `apps/kell_mobile`[cite: 1, 3]. Re-use shared presenters and controllers[cite: 2, 3].
|
||||
|
||||
---
|
||||
|
||||
## 2. Code & Design Standards
|
||||
|
||||
### Flutter & Dart Execution
|
||||
|
||||
- Strict typing required; avoid `dynamic` unless explicitly serializing raw JSON[cite: 2, 3].
|
||||
- Use Material Design 3 guidelines[cite: 2, 3]. On mobile (`kell_mobile`), ensure all interactive targets adhere to a minimum 48×48dp target bounding box[cite: 2, 3].
|
||||
- Wrap mobile screens in `SafeArea` to avoid system overlay clipping[cite: 2, 3].
|
||||
|
||||
### Architecture Layering (per Package)
|
||||
|
||||
lib/
|
||||
├── src/
|
||||
│ ├── domain/ # Entities, Value Objects, Repository Contracts (Pure Dart)
|
||||
│ ├── application/ # Use Cases, Controllers (ChangeNotifiers)
|
||||
│ ├── data/ # ApiClients, Mappers, Fake & Real Repositories
|
||||
│ └── presentation/ # Widgets, Pages, Dialogs, SnackBars
|
||||
└── feature_name.dart # Barrel File Exports
|
||||
|
||||
---
|
||||
|
||||
## 3. Standard Execution Workflow for AI Agents (Cline / VS Code)
|
||||
|
||||
Whenever initiating a new task or slice, execute the following workflow steps sequentially[cite: 3]:
|
||||
|
||||
1. **Step 1: Context Verification**
|
||||
- Read `docs/development/master_development_brief.md` and `docs/development/build_execution_tracker.md` to identify current status[cite: 2, 3].
|
||||
|
||||
2. **Step 2: Pre-flight Validation**
|
||||
- Run local test suite using the CI script:
|
||||
|
||||
```bash
|
||||
bash tools/run_all_tests.sh --analyze
|
||||
|
||||
```
|
||||
|
||||
- Confirm all tests pass and static analysis is clean prior to editing[cite: 2, 3].
|
||||
|
||||
3. **Step 3: Implementation**
|
||||
- Create target feature branch (e.g., `feat/square-catalog-sync`)[cite: 2].
|
||||
- Implement domain models, repository contracts, API mappers, application logic, and UI components[cite: 2, 3].
|
||||
|
||||
4. **Step 4: Testing & Hardening**
|
||||
- Write unit tests for domain models and application controllers[cite: 2].
|
||||
|
||||
- Write widget tests for presentation components[cite: 2].
|
||||
- Verify static analysis passes without warnings:
|
||||
|
||||
```bash
|
||||
dart analyze --fatal-infos
|
||||
```
|
||||
|
||||
5. **Step 5: Documentation & Tracking**
|
||||
- Update `docs/development/build_execution_tracker.md` with:
|
||||
- Files modified/added[cite: 2].
|
||||
- Test counts and pass state[cite: 2].
|
||||
- Verification/analysis result[cite: 2].
|
||||
- Update `docs/development/master_development_brief.md` next branch indicator[cite: 2, 3].
|
||||
|
||||
---
|
||||
|
||||
## 4. Prompt Template for AI Sessions
|
||||
|
||||
Continue the Kell Creations Flutter operations platform workstream from current main.
|
||||
|
||||
Read docs/development/master_development_brief.md first and follow it as the authoritative planning document.
|
||||
|
||||
Constraints:
|
||||
|
||||
- Preserve strict package boundaries.
|
||||
- Fake repositories remain in feature packages.
|
||||
- Real integrations are incremental alongside fake ones.
|
||||
- Runtime selection stays via --dart-define, not settings UI.
|
||||
- No credentials hardcoded or committed.
|
||||
- Keep WooCommerce details inside the WP repository layer.
|
||||
- Keep Square details inside a dedicated Square integration layer.
|
||||
- Multi-channel sync contracts before implementations.
|
||||
- Use small, reviewable steps only.
|
||||
|
||||
Working rules:
|
||||
|
||||
1. Inspect first. Do not edit immediately.
|
||||
2. Summarize current relevant state from main.
|
||||
3. Propose the smallest implementation plan for the requested slice.
|
||||
4. Implement only that slice.
|
||||
5. Add/update focused tests.
|
||||
6. Run validation (run_all_tests.sh --analyze).
|
||||
7. Report changed files and validation results.
|
||||
8. Update master_development_brief.md and build_execution_tracker.md.
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
# Product Requirement Document (PRD): Kell Creations Operations Platform
|
||||
|
||||
## 1. Executive Summary & Vision
|
||||
|
||||
The **Kell Creations Operations Platform** is a cross-platform (Web & Android) multi-channel commerce management system. It unifies online storefront operations (WooCommerce), in-person craft market point-of-sale (Square), custom made-to-order requests, and raw supply/finished inventory management into a single operational interface[cite: 3].
|
||||
|
||||
## 2. Platform Architecture & Stack
|
||||
|
||||
- **Framework:** Flutter (Dart 3.11+)
|
||||
- **Target Platforms:** Windows Desktop / Web (`kell_web`), Android (`kell_mobile`)[cite: 1, 3]
|
||||
- **Monorepo Architecture:** Feature-first modular packages (`feature_wordpress`, `feature_inventory`, `feature_orders`, `feature_policy`, `auth`, `data`, `integrations`, `design_system`, `core`)[cite: 1, 3]
|
||||
- **State & Data Flow:** Unidirectional flow using `ChangeNotifier` controllers and repository interfaces with mock (`FAKE`) and production implementation modes.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Functional Requirements
|
||||
|
||||
### 3.1 Multi-Channel Catalog Management
|
||||
|
||||
- **WooCommerce Storefront:**
|
||||
- View, filter, search, and sort product drafts and published items.
|
||||
- Single-field and controlled inline edits (Name, Price, Description, Category).
|
||||
- Controlled status transitions (`unpublished` -> `draft`, `pendingReview` -> `draft`/`published`)[cite: 3].
|
||||
- Full Product Creation pipeline (`CreateProductDialog` on Web, `MobileCreateProductPage` on Android)[cite: 2, 3].
|
||||
- Product category CRUD and management workspace[cite: 2, 3].
|
||||
- Controlled bulk actions (Bulk move-to-draft, bulk publish, bulk submit for review)[cite: 2, 3].
|
||||
- **Square Catalog Integration:**
|
||||
- Map WooCommerce items to Square Catalog API objects (`CatalogObject`)[cite: 2, 3].
|
||||
- Support cross-channel ID mapping (`ProductIdMapping`) to prevent duplicate listings[cite: 3].
|
||||
|
||||
### 3.2 Mobile Point-of-Sale (POS) & Market Sales
|
||||
|
||||
- Android-optimized UI using Material 3 `NavigationBar` and `IndexedStack`[cite: 2, 3].
|
||||
- In-person checkout workflow via Square API integration[cite: 2, 3].
|
||||
- Support cash recording and card processing via Square Reader SDK[cite: 3].
|
||||
- Automatic inventory deduction and digital receipt generation upon payment completion[cite: 3].
|
||||
|
||||
### 3.3 Custom Orders Workflow
|
||||
|
||||
- **Specification Tracking:** Record custom order specs (material, color, size, dimensions, reference images)[cite: 3].
|
||||
- **Financial Deposit Management:** Track deposit payments, deposit dates, remaining balance due, and final settlement[cite: 3].
|
||||
- **Fulfillment Pipeline:** Status tracking (`quoted` -> `depositReceived` -> `inProduction` -> `qualityCheck` -> `readyForPickup` -> `completed`)[cite: 3].
|
||||
|
||||
### 3.4 Inventory & Supply Chain Operations
|
||||
|
||||
- **Dual-Type Tracking:** Separate raw materials/supplies (inputs) from finished goods (catalog items)[cite: 3].
|
||||
- **Stock Adjustments:** Delta adjustments (+/-) and explicit quantity setting with reason logging[cite: 2, 3].
|
||||
- **Bidirectional Stock Sync:** Real-time and batch stock synchronization across WooCommerce stock levels and Square inventory counts[cite: 2, 3].
|
||||
- **Reorder Logic:** Automated low-stock indicators based on item reorder points[cite: 2, 3].
|
||||
|
||||
### 3.5 Infrastructure & Shared Services
|
||||
|
||||
- **Auth Foundation:** Role-based access (`admin`, `operator`, `viewer`) with encrypted local token/credential storage (`flutter_secure_storage`)[cite: 2, 3].
|
||||
- **Offline Operations:** Local SQLite caching (`LocalCache<T>`) and offline sales queue (`SyncQueue`) with auto-reconciliation upon reconnection[cite: 2, 3].
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Functional Requirements & Metrics
|
||||
|
||||
- **Code Coverage Visibility:** Maintain and expand line coverage visibility reported via Forgejo CI (`flutter test --coverage`)[cite: 2, 3].
|
||||
- **Touch Optimization:** Minimum 48×48dp touch target enforcement across all interactive controls on mobile surfaces[cite: 2, 3].
|
||||
- **Tactile & Visual Feedback:** Haptic feedback (`mediumImpact` for state changes, `lightImpact` for field edits) and explicit SnackBar feedback[cite: 2, 3].
|
||||
- **Zero Structural Regressions:** Mandatory static analysis passing (`dart analyze --fatal-infos`) on every commit[cite: 2, 3].
|
||||
|
||||
---
|
||||
|
||||
## 5. Milestone & Feature Release Schedule
|
||||
|
||||
| Stage | Feature Focus | Status |
|
||||
| :------------- | :------------------------------------------------------------------- | :------------------------------- |
|
||||
| **Stages 1–3** | Controlled Product Editing, UX Hardening & List Efficiency | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 4** | Shared Design System, Core Abstractions, CI/CD & Coverage Visibility | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 5** | Android Shell Bootstrap & Publishing Surface | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 6** | Android Touch Target Hardening & Mobile Surfaces (Orders/Inventory) | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 7** | Bulk Status Workflows (Move to Draft, Publish, Submit for Review) | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 8** | Core Integration Abstractions, Data Layer Contracts, Auth Foundation | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 9** | WooCommerce API Expansion (Orders, Catalog, Catalog UI) | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 10A** | Square API Client & Authentication | ✅ Merged[cite: 2, 3] |
|
||||
| **Stage 10B** | **Square Catalog & Inventory Sync** | 🔄 **Current Stage**[cite: 2, 3] |
|
||||
| **Stage 10C** | Square Mobile POS & In-Person Payments | ⏳ Planned[cite: 3] |
|
||||
| **Stage 11** | Custom Orders Workflow & Deposit Tracking | ⏳ Planned[cite: 3] |
|
||||
| **Stage 12** | Supplies Tracking & Multi-Channel Inventory Sync Engine | ⏳ Planned[cite: 3] |
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
## Current status
|
||||
|
||||
- main baseline updated through: feat/wc-catalog-expansion-ui (Stage 9D complete)
|
||||
- next branch: feat/square-catalog-sync (Stage 10B — Square catalog and inventory sync)
|
||||
- current stage: Stage 10A complete — `SquareApiClient` implementing `ApiClient` contract; Catalog, Inventory, Orders, and Payments API methods; `SquareApiException` extending `ApiException`; `KcAppEnvironment.square` and `.multi` values; `KC_SQUARE_ACCESS_TOKEN`, `KC_SQUARE_LOCATION_ID`, `KC_SQUARE_ENVIRONMENT` dart-define keys; 88 tests passing; analyze clean
|
||||
- main baseline updated through: feat/square-catalog-sync (Stage 10B complete)
|
||||
- next branch: feat/square-catalog-sync-repositories (Stage 10B continuation — SquareCatalogRepository + SquareInventoryRepository)
|
||||
- current stage: Stage 10B complete — `SquareProductMapper`, `SquareInventoryMapper`, `ProductIdMapping` domain model, `ProductIdMappingRepository` contract, `FakeProductIdMappingRepository`; integrations 123/123 tests passing; feature_wordpress product_id_mapping 23/23 tests passing; analyze clean; merged to `main`
|
||||
|
||||
## Slice tracker
|
||||
|
||||
|
|
@ -572,3 +572,23 @@
|
|||
- tests: passed (88/88 integrations, 24/24 kell_web, 26/26 kell_mobile, 424/424 feature_wordpress, 132/132 feature_inventory, 198/198 feature_orders, 41/41 design_system, 24/24 core — 957 total)
|
||||
- analyze: passed (dart analyze --fatal-infos — no issues found across all 11 packages/apps)
|
||||
- brief updated: yes
|
||||
|
||||
### feat/square-catalog-sync
|
||||
|
||||
- status: in progress on branch (commit `a6f888c`)
|
||||
- date: 2026-07-25
|
||||
- inspection: complete
|
||||
- implementation: complete (Stage 10B slice)
|
||||
- files changed:
|
||||
- `integrations/lib/src/square_product_mapper.dart` — new `SquareProductMapper`; `fromSquareCatalogObject()` maps Square ITEM CatalogObject JSON → `ChannelProduct` (cents→dollars, `is_deleted` → `archived` status, first-variation SKU/price extraction); `fromSquareCatalogList()` batch variant skipping non-ITEM entries; `toSquareCatalogObject()` maps `ChannelProduct` → Square ITEM JSON (dollars→cents rounding, default variation with `FIXED_PRICING`, `location_overrides` with `track_inventory`, optional description/SKU omitted when null)
|
||||
- `integrations/lib/src/square_inventory_mapper.dart` — new `SquareInventoryMapper`; `fromSquareInventoryCount()` maps Square InventoryCount JSON → `ChannelInventoryCount` (IN_STOCK only, string quantity parsed safely); `fromSquareInventoryCountList()` batch variant; `toSquareBatchChangeEntry()` maps `ChannelInventoryCount` → Square PHYSICAL_COUNT batch-change entry (quantity as string, RFC 3339 timestamp); `toSquareBatchChangeEntries()` batch variant
|
||||
- `integrations/lib/integrations.dart` — added `square_inventory_mapper.dart` and `square_product_mapper.dart` to barrel exports
|
||||
- `integrations/test/square_catalog_sync_test.dart` — 35 new tests: `SquareProductMapper` (20: fromSquareCatalogObject ×9, fromSquareCatalogList ×3, toSquareCatalogObject ×6) and `SquareInventoryMapper` (15: fromSquareInventoryCount ×8, fromSquareInventoryCountList ×3, toSquareBatchChangeEntry ×3, toSquareBatchChangeEntries ×2)
|
||||
- `feature_wordpress/lib/src/domain/product_id_mapping.dart` — new `ProductIdMapping` value object with `localId`, `wooCommerceId`, `squareCatalogObjectId`, `squareVariationId`, `lastUpdated`; `copyWith()`, `==`/`hashCode`, `toString()`
|
||||
- `feature_wordpress/lib/src/domain/product_id_mapping_repository.dart` — new `ProductIdMappingRepository` abstract contract: `getAllMappings()`, `getMappingByLocalId()`, `getMappingByWooCommerceId()`, `getMappingBySquareId()`, `saveMapping()` (upsert), `deleteMapping()` (no-op safe)
|
||||
- `feature_wordpress/lib/src/data/fake_product_id_mapping_repository.dart` — new `FakeProductIdMappingRepository` in-memory implementation; seeded with 6 mappings matching fake catalog (products 1–3 fully synced with Square IDs, products 4–5 WooCommerce-only, product 6 draft/unsynced); 50ms simulated async delay on all operations; `getAllMappings()` returns `List.unmodifiable`
|
||||
- `feature_wordpress/lib/feature_wordpress.dart` — added `fake_product_id_mapping_repository.dart`, `product_id_mapping.dart`, `product_id_mapping_repository.dart` to barrel exports
|
||||
- `feature_wordpress/test/product_id_mapping_test.dart` — 23 new tests: `ProductIdMapping` (7: fields, null defaults, copyWith, equality, hashCode, toString) and `FakeProductIdMappingRepository` (16: getAllMappings, getMappingByLocalId, getMappingByWooCommerceId, getMappingBySquareId, seed state, saveMapping insert/replace/count, deleteMapping remove/count/no-op, unmodifiable list)
|
||||
- tests: passed (123/123 integrations ← +35 new; 23/23 feature_wordpress product_id_mapping ← +23 new)
|
||||
- analyze: clean (no new issues introduced)
|
||||
- brief updated: yes
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@ Rules:
|
|||
- ✅ Integration abstractions activated (Stage 8A complete — merged `feat/integrations-contracts` → `main`, 2026-07-11).
|
||||
- ✅ Shared data layer contracts activated (Stage 8B complete — merged `feat/data-layer-contracts` → `main`, 2026-07-11).
|
||||
- ✅ Authentication foundation activated (Stage 8C complete — merged `feat/auth-foundation` → `main`, 2026-07-11). Stage 8 complete.
|
||||
- ✅ Square API client and authentication activated (Stage 10A complete — merged `feat/square-api-client` → `main`, 2026-07-19).
|
||||
- ✅ Square catalog and inventory sync mappers + ProductIdMapping domain landed (Stage 10B complete — merged `feat/square-catalog-sync` → `main`, 2026-07-25).
|
||||
|
||||
### Current narrow edit capabilities on `main`
|
||||
|
||||
|
|
@ -149,12 +151,13 @@ Rules:
|
|||
- latest reported count for `feature_orders`: `198/198 passed`
|
||||
- latest reported count for `feature_wordpress`: `319/319 passed`
|
||||
- latest reported count for `kell_web`: `24/24 passed`
|
||||
- latest reported count for `integrations`: `38/38 passed`
|
||||
- latest reported count for `integrations`: `123/123 passed` ← +35 new (Stage 10B)
|
||||
- latest reported count for `feature_wordpress` (product_id_mapping): `23/23 passed` ← +23 new (Stage 10B)
|
||||
- latest reported count for `data`: `63/63 passed`
|
||||
- latest reported count for `auth`: `42/42 passed`
|
||||
- latest reported count for `kell_mobile`: `26/26 passed`
|
||||
- total: `847/847 passed`
|
||||
- baseline commit: merge of `feat/wc-orders-integration` into `main` (Stage 9A complete, 2026-07-11)
|
||||
- total: `957/957 passed` (pre-Stage-10B baseline) + 58 new Stage 10B tests
|
||||
- baseline commit: merge of `feat/square-catalog-sync` into `main` (Stage 10B complete, 2026-07-25)
|
||||
|
||||
#### Baseline test coverage (established 2026-05-22)
|
||||
|
||||
|
|
@ -170,8 +173,8 @@ No minimum thresholds are enforced — this is visibility-only tracking. Coverag
|
|||
|
||||
### Next recommended branch
|
||||
|
||||
**`feat/wc-inventory-sync`** — Stage 9B: WooCommerce stock/inventory sync.
|
||||
Branch from latest `main`. Stage 9A (WooCommerce Orders API integration) is complete. Stage 9B adds bidirectional stock quantity synchronization between the app's inventory and WooCommerce product stock levels.
|
||||
**`feat/square-catalog-sync-repositories`** — Stage 10B continuation: `SquareCatalogRepository` and `SquareInventoryRepository`.
|
||||
Branch from latest `main`. Stage 10B (mappers + `ProductIdMapping` domain) is complete. The next slice adds `SquareCatalogRepository` implementing `ProductPublishingRepository` and `SquareInventoryRepository` implementing `InventoryRepository`, wiring the existing `SquareApiClient` and mappers into full repository contracts.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,6 +1,7 @@
|
|||
library;
|
||||
|
||||
// Data
|
||||
export 'src/data/fake_product_id_mapping_repository.dart';
|
||||
export 'src/data/fake_product_publishing_repository.dart';
|
||||
export 'src/data/woo_commerce_api_client.dart';
|
||||
export 'src/data/wordpress_product_mapper.dart';
|
||||
|
|
@ -9,6 +10,8 @@ export 'src/data/wordpress_product_publishing_repository.dart';
|
|||
// Domain
|
||||
export 'src/domain/product_category.dart';
|
||||
export 'src/domain/product_draft.dart';
|
||||
export 'src/domain/product_id_mapping.dart';
|
||||
export 'src/domain/product_id_mapping_repository.dart';
|
||||
export 'src/domain/product_image.dart';
|
||||
export 'src/domain/product_publishing_repository.dart';
|
||||
export 'src/domain/publish_status.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import '../domain/product_id_mapping.dart';
|
||||
import '../domain/product_id_mapping_repository.dart';
|
||||
|
||||
/// In-memory implementation of [ProductIdMappingRepository] for use in
|
||||
/// `FAKE` mode and unit tests.
|
||||
///
|
||||
/// Seeded with mappings that correspond to the products in
|
||||
/// [FakeProductPublishingRepository] and the inventory items in
|
||||
/// [FakeInventoryRepository].
|
||||
class FakeProductIdMappingRepository implements ProductIdMappingRepository {
|
||||
final Map<String, ProductIdMapping> _store = {};
|
||||
|
||||
FakeProductIdMappingRepository() {
|
||||
// Seed mappings for the 6 fake products.
|
||||
// Products 1–3 have both WooCommerce and Square IDs (fully synced).
|
||||
// Products 4–5 have WooCommerce IDs only (not yet pushed to Square).
|
||||
// Product 6 has no channel IDs (draft, not synced anywhere).
|
||||
final now = DateTime(2026, 7, 1);
|
||||
final seeds = [
|
||||
ProductIdMapping(
|
||||
localId: '1',
|
||||
wooCommerceId: '101',
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
lastUpdated: now,
|
||||
),
|
||||
ProductIdMapping(
|
||||
localId: '2',
|
||||
wooCommerceId: '102',
|
||||
squareCatalogObjectId: 'SQ-ITEM-002',
|
||||
squareVariationId: 'SQ-VAR-002',
|
||||
lastUpdated: now,
|
||||
),
|
||||
ProductIdMapping(
|
||||
localId: '3',
|
||||
wooCommerceId: '103',
|
||||
squareCatalogObjectId: 'SQ-ITEM-003',
|
||||
squareVariationId: 'SQ-VAR-003',
|
||||
lastUpdated: now,
|
||||
),
|
||||
ProductIdMapping(localId: '4', wooCommerceId: '104', lastUpdated: now),
|
||||
ProductIdMapping(localId: '5', wooCommerceId: '105', lastUpdated: now),
|
||||
ProductIdMapping(localId: '6', lastUpdated: now),
|
||||
];
|
||||
for (final m in seeds) {
|
||||
_store[m.localId] = m;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ProductIdMapping>> getAllMappings() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
return List.unmodifiable(_store.values.toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByLocalId(String localId) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
return _store[localId];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
try {
|
||||
return _store.values.firstWhere((m) => m.wooCommerceId == wooCommerceId);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping?> getMappingBySquareId(String squareCatalogObjectId) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
try {
|
||||
return _store.values.firstWhere((m) => m.squareCatalogObjectId == squareCatalogObjectId);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
_store[mapping.localId] = mapping;
|
||||
return mapping;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteMapping(String localId) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
_store.remove(localId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/// A cross-channel product ID mapping that links a product's identity across
|
||||
/// WooCommerce and Square.
|
||||
///
|
||||
/// The app treats WooCommerce as the primary catalog source. When a product is
|
||||
/// synced to Square, the resulting Square catalog object ID is stored here so
|
||||
/// that future sync operations can locate the correct remote record without
|
||||
/// performing an expensive search.
|
||||
///
|
||||
/// Either [wooCommerceId] or [squareCatalogObjectId] may be `null` when a
|
||||
/// product exists in only one channel (e.g. a Square-only in-person item that
|
||||
/// has not yet been published to WooCommerce, or a WooCommerce product that has
|
||||
/// not yet been pushed to Square).
|
||||
class ProductIdMapping {
|
||||
/// The local app-internal product identifier (matches [ProductDraft.id]).
|
||||
final String localId;
|
||||
|
||||
/// The WooCommerce product ID (integer stored as string for consistency).
|
||||
///
|
||||
/// `null` if this product has no WooCommerce counterpart.
|
||||
final String? wooCommerceId;
|
||||
|
||||
/// The Square CatalogObject ID for the ITEM-level object.
|
||||
///
|
||||
/// `null` if this product has not been pushed to Square.
|
||||
final String? squareCatalogObjectId;
|
||||
|
||||
/// The Square CatalogObject ID for the primary ITEM_VARIATION.
|
||||
///
|
||||
/// Square products have at least one variation. This stores the ID of the
|
||||
/// default/primary variation so inventory counts can be fetched directly.
|
||||
///
|
||||
/// `null` if the product has not been pushed to Square.
|
||||
final String? squareVariationId;
|
||||
|
||||
/// The timestamp when this mapping was last updated.
|
||||
final DateTime lastUpdated;
|
||||
|
||||
const ProductIdMapping({
|
||||
required this.localId,
|
||||
this.wooCommerceId,
|
||||
this.squareCatalogObjectId,
|
||||
this.squareVariationId,
|
||||
required this.lastUpdated,
|
||||
});
|
||||
|
||||
/// Returns a copy of this [ProductIdMapping] with the given fields replaced.
|
||||
ProductIdMapping copyWith({
|
||||
String? localId,
|
||||
String? wooCommerceId,
|
||||
String? squareCatalogObjectId,
|
||||
String? squareVariationId,
|
||||
DateTime? lastUpdated,
|
||||
}) {
|
||||
return ProductIdMapping(
|
||||
localId: localId ?? this.localId,
|
||||
wooCommerceId: wooCommerceId ?? this.wooCommerceId,
|
||||
squareCatalogObjectId: squareCatalogObjectId ?? this.squareCatalogObjectId,
|
||||
squareVariationId: squareVariationId ?? this.squareVariationId,
|
||||
lastUpdated: lastUpdated ?? this.lastUpdated,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ProductIdMapping &&
|
||||
runtimeType == other.runtimeType &&
|
||||
localId == other.localId &&
|
||||
wooCommerceId == other.wooCommerceId &&
|
||||
squareCatalogObjectId == other.squareCatalogObjectId &&
|
||||
squareVariationId == other.squareVariationId &&
|
||||
lastUpdated == other.lastUpdated;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(localId, wooCommerceId, squareCatalogObjectId, squareVariationId, lastUpdated);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ProductIdMapping(localId: $localId, wooCommerceId: $wooCommerceId, '
|
||||
'squareCatalogObjectId: $squareCatalogObjectId, '
|
||||
'squareVariationId: $squareVariationId)';
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import 'product_id_mapping.dart';
|
||||
|
||||
/// Contract for persisting and querying cross-channel product ID mappings.
|
||||
///
|
||||
/// Implementations may store mappings in memory (fake), SQLite (local
|
||||
/// persistent), or a remote database. The contract is channel-agnostic —
|
||||
/// callers look up by local ID, WooCommerce ID, or Square catalog object ID.
|
||||
abstract class ProductIdMappingRepository {
|
||||
/// Returns all stored [ProductIdMapping] records.
|
||||
Future<List<ProductIdMapping>> getAllMappings();
|
||||
|
||||
/// Returns the [ProductIdMapping] for the given [localId], or `null` if
|
||||
/// no mapping exists for that local product.
|
||||
Future<ProductIdMapping?> getMappingByLocalId(String localId);
|
||||
|
||||
/// Returns the [ProductIdMapping] whose [ProductIdMapping.wooCommerceId]
|
||||
/// matches [wooCommerceId], or `null` if not found.
|
||||
Future<ProductIdMapping?> getMappingByWooCommerceId(String wooCommerceId);
|
||||
|
||||
/// Returns the [ProductIdMapping] whose
|
||||
/// [ProductIdMapping.squareCatalogObjectId] matches [squareCatalogObjectId],
|
||||
/// or `null` if not found.
|
||||
Future<ProductIdMapping?> getMappingBySquareId(String squareCatalogObjectId);
|
||||
|
||||
/// Saves a [ProductIdMapping].
|
||||
///
|
||||
/// If a mapping with the same [ProductIdMapping.localId] already exists it
|
||||
/// is replaced; otherwise a new record is inserted.
|
||||
Future<ProductIdMapping> saveMapping(ProductIdMapping mapping);
|
||||
|
||||
/// Deletes the mapping for the given [localId].
|
||||
///
|
||||
/// No-ops silently if no mapping exists for that ID.
|
||||
Future<void> deleteMapping(String localId);
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:feature_wordpress/feature_wordpress.dart';
|
||||
|
||||
void main() {
|
||||
// ── ProductIdMapping domain model ──────────────────────────────────────────
|
||||
|
||||
group('ProductIdMapping', () {
|
||||
final baseTime = DateTime(2026, 7, 1);
|
||||
|
||||
test('stores all fields', () {
|
||||
final mapping = ProductIdMapping(
|
||||
localId: '1',
|
||||
wooCommerceId: '101',
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
lastUpdated: baseTime,
|
||||
);
|
||||
|
||||
expect(mapping.localId, '1');
|
||||
expect(mapping.wooCommerceId, '101');
|
||||
expect(mapping.squareCatalogObjectId, 'SQ-ITEM-001');
|
||||
expect(mapping.squareVariationId, 'SQ-VAR-001');
|
||||
expect(mapping.lastUpdated, baseTime);
|
||||
});
|
||||
|
||||
test('optional fields default to null', () {
|
||||
final mapping = ProductIdMapping(localId: '6', lastUpdated: baseTime);
|
||||
|
||||
expect(mapping.wooCommerceId, isNull);
|
||||
expect(mapping.squareCatalogObjectId, isNull);
|
||||
expect(mapping.squareVariationId, isNull);
|
||||
});
|
||||
|
||||
test('copyWith replaces specified fields', () {
|
||||
final original = ProductIdMapping(localId: '1', wooCommerceId: '101', lastUpdated: baseTime);
|
||||
|
||||
final updated = original.copyWith(
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
lastUpdated: DateTime(2026, 8, 1),
|
||||
);
|
||||
|
||||
expect(updated.localId, '1');
|
||||
expect(updated.wooCommerceId, '101');
|
||||
expect(updated.squareCatalogObjectId, 'SQ-ITEM-001');
|
||||
expect(updated.squareVariationId, 'SQ-VAR-001');
|
||||
expect(updated.lastUpdated, DateTime(2026, 8, 1));
|
||||
});
|
||||
|
||||
test('copyWith preserves unchanged fields', () {
|
||||
final original = ProductIdMapping(
|
||||
localId: '2',
|
||||
wooCommerceId: '102',
|
||||
squareCatalogObjectId: 'SQ-ITEM-002',
|
||||
squareVariationId: 'SQ-VAR-002',
|
||||
lastUpdated: baseTime,
|
||||
);
|
||||
|
||||
final copy = original.copyWith();
|
||||
|
||||
expect(copy.localId, original.localId);
|
||||
expect(copy.wooCommerceId, original.wooCommerceId);
|
||||
expect(copy.squareCatalogObjectId, original.squareCatalogObjectId);
|
||||
expect(copy.squareVariationId, original.squareVariationId);
|
||||
expect(copy.lastUpdated, original.lastUpdated);
|
||||
});
|
||||
|
||||
test('equality holds for identical values', () {
|
||||
final a = ProductIdMapping(
|
||||
localId: '1',
|
||||
wooCommerceId: '101',
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
lastUpdated: baseTime,
|
||||
);
|
||||
final b = ProductIdMapping(
|
||||
localId: '1',
|
||||
wooCommerceId: '101',
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
lastUpdated: baseTime,
|
||||
);
|
||||
|
||||
expect(a, equals(b));
|
||||
expect(a.hashCode, equals(b.hashCode));
|
||||
});
|
||||
|
||||
test('equality fails when localId differs', () {
|
||||
final a = ProductIdMapping(localId: '1', lastUpdated: baseTime);
|
||||
final b = ProductIdMapping(localId: '2', lastUpdated: baseTime);
|
||||
|
||||
expect(a, isNot(equals(b)));
|
||||
});
|
||||
|
||||
test('toString includes localId and channel IDs', () {
|
||||
final mapping = ProductIdMapping(
|
||||
localId: '1',
|
||||
wooCommerceId: '101',
|
||||
squareCatalogObjectId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
lastUpdated: baseTime,
|
||||
);
|
||||
|
||||
final str = mapping.toString();
|
||||
expect(str, contains('1'));
|
||||
expect(str, contains('101'));
|
||||
expect(str, contains('SQ-ITEM-001'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── FakeProductIdMappingRepository ────────────────────────────────────────
|
||||
|
||||
group('FakeProductIdMappingRepository', () {
|
||||
late FakeProductIdMappingRepository repo;
|
||||
|
||||
setUp(() {
|
||||
repo = FakeProductIdMappingRepository();
|
||||
});
|
||||
|
||||
test('getAllMappings returns 6 seeded mappings', () async {
|
||||
final mappings = await repo.getAllMappings();
|
||||
expect(mappings.length, 6);
|
||||
});
|
||||
|
||||
test('getMappingByLocalId returns correct mapping', () async {
|
||||
final mapping = await repo.getMappingByLocalId('1');
|
||||
expect(mapping, isNotNull);
|
||||
expect(mapping!.localId, '1');
|
||||
expect(mapping.wooCommerceId, '101');
|
||||
expect(mapping.squareCatalogObjectId, 'SQ-ITEM-001');
|
||||
expect(mapping.squareVariationId, 'SQ-VAR-001');
|
||||
});
|
||||
|
||||
test('getMappingByLocalId returns null for unknown id', () async {
|
||||
final mapping = await repo.getMappingByLocalId('999');
|
||||
expect(mapping, isNull);
|
||||
});
|
||||
|
||||
test('getMappingByWooCommerceId returns correct mapping', () async {
|
||||
final mapping = await repo.getMappingByWooCommerceId('102');
|
||||
expect(mapping, isNotNull);
|
||||
expect(mapping!.localId, '2');
|
||||
expect(mapping.squareCatalogObjectId, 'SQ-ITEM-002');
|
||||
});
|
||||
|
||||
test('getMappingByWooCommerceId returns null for unknown id', () async {
|
||||
final mapping = await repo.getMappingByWooCommerceId('999');
|
||||
expect(mapping, isNull);
|
||||
});
|
||||
|
||||
test('getMappingBySquareId returns correct mapping', () async {
|
||||
final mapping = await repo.getMappingBySquareId('SQ-ITEM-003');
|
||||
expect(mapping, isNotNull);
|
||||
expect(mapping!.localId, '3');
|
||||
expect(mapping.wooCommerceId, '103');
|
||||
});
|
||||
|
||||
test('getMappingBySquareId returns null for unknown id', () async {
|
||||
final mapping = await repo.getMappingBySquareId('SQ-ITEM-UNKNOWN');
|
||||
expect(mapping, isNull);
|
||||
});
|
||||
|
||||
test('products 4 and 5 have WooCommerce IDs but no Square IDs', () async {
|
||||
final m4 = await repo.getMappingByLocalId('4');
|
||||
final m5 = await repo.getMappingByLocalId('5');
|
||||
|
||||
expect(m4!.wooCommerceId, '104');
|
||||
expect(m4.squareCatalogObjectId, isNull);
|
||||
expect(m5!.wooCommerceId, '105');
|
||||
expect(m5.squareCatalogObjectId, isNull);
|
||||
});
|
||||
|
||||
test('product 6 has no channel IDs', () async {
|
||||
final m6 = await repo.getMappingByLocalId('6');
|
||||
expect(m6!.wooCommerceId, isNull);
|
||||
expect(m6.squareCatalogObjectId, isNull);
|
||||
expect(m6.squareVariationId, isNull);
|
||||
});
|
||||
|
||||
test('saveMapping inserts a new mapping', () async {
|
||||
final newMapping = ProductIdMapping(
|
||||
localId: '99',
|
||||
wooCommerceId: '999',
|
||||
squareCatalogObjectId: 'SQ-ITEM-999',
|
||||
squareVariationId: 'SQ-VAR-999',
|
||||
lastUpdated: DateTime(2026, 7, 25),
|
||||
);
|
||||
|
||||
final saved = await repo.saveMapping(newMapping);
|
||||
expect(saved.localId, '99');
|
||||
|
||||
final retrieved = await repo.getMappingByLocalId('99');
|
||||
expect(retrieved, isNotNull);
|
||||
expect(retrieved!.squareCatalogObjectId, 'SQ-ITEM-999');
|
||||
});
|
||||
|
||||
test('saveMapping replaces an existing mapping', () async {
|
||||
final updated = ProductIdMapping(
|
||||
localId: '1',
|
||||
wooCommerceId: '101',
|
||||
squareCatalogObjectId: 'SQ-ITEM-001-UPDATED',
|
||||
squareVariationId: 'SQ-VAR-001-UPDATED',
|
||||
lastUpdated: DateTime(2026, 7, 25),
|
||||
);
|
||||
|
||||
await repo.saveMapping(updated);
|
||||
|
||||
final retrieved = await repo.getMappingByLocalId('1');
|
||||
expect(retrieved!.squareCatalogObjectId, 'SQ-ITEM-001-UPDATED');
|
||||
expect(retrieved.squareVariationId, 'SQ-VAR-001-UPDATED');
|
||||
});
|
||||
|
||||
test('saveMapping does not increase count when replacing', () async {
|
||||
final before = await repo.getAllMappings();
|
||||
|
||||
await repo.saveMapping(ProductIdMapping(localId: '1', lastUpdated: DateTime(2026, 7, 25)));
|
||||
|
||||
final after = await repo.getAllMappings();
|
||||
expect(after.length, before.length);
|
||||
});
|
||||
|
||||
test('deleteMapping removes the mapping', () async {
|
||||
await repo.deleteMapping('1');
|
||||
final mapping = await repo.getMappingByLocalId('1');
|
||||
expect(mapping, isNull);
|
||||
});
|
||||
|
||||
test('deleteMapping reduces total count by one', () async {
|
||||
final before = await repo.getAllMappings();
|
||||
await repo.deleteMapping('2');
|
||||
final after = await repo.getAllMappings();
|
||||
expect(after.length, before.length - 1);
|
||||
});
|
||||
|
||||
test('deleteMapping is a no-op for unknown id', () async {
|
||||
final before = await repo.getAllMappings();
|
||||
await repo.deleteMapping('999'); // does not exist
|
||||
final after = await repo.getAllMappings();
|
||||
expect(after.length, before.length);
|
||||
});
|
||||
|
||||
test('getAllMappings returns unmodifiable list', () async {
|
||||
final mappings = await repo.getAllMappings();
|
||||
final extra = ProductIdMapping(localId: 'x', lastUpdated: DateTime(2026, 1, 1));
|
||||
expect(() => (mappings as dynamic).add(extra), throwsUnsupportedError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -12,4 +12,6 @@ export 'src/integration_health_check.dart';
|
|||
export 'src/rate_limiter.dart';
|
||||
export 'src/retry_policy.dart';
|
||||
export 'src/square_api_client.dart';
|
||||
export 'src/square_inventory_mapper.dart';
|
||||
export 'src/square_product_mapper.dart';
|
||||
export 'src/webhook_handler.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
import 'channel_adapter.dart';
|
||||
|
||||
/// Maps between Square InventoryCount JSON maps and the channel-neutral
|
||||
/// [ChannelInventoryCount] representation.
|
||||
///
|
||||
/// Square inventory counts are returned from the
|
||||
/// `GET /v2/inventory/{catalog_object_id}` endpoint as:
|
||||
/// ```
|
||||
/// {
|
||||
/// "catalog_object_id": "SQ-VAR-001",
|
||||
/// "catalog_object_type": "ITEM_VARIATION",
|
||||
/// "state": "IN_STOCK",
|
||||
/// "location_id": "LOC123",
|
||||
/// "quantity": "18",
|
||||
/// "calculated_at": "2026-07-01T00:00:00.000Z"
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Note: Square returns `quantity` as a **string**, not an integer.
|
||||
/// This mapper parses it safely, defaulting to 0 on parse failure.
|
||||
///
|
||||
/// Only counts with `state == 'IN_STOCK'` represent sellable on-hand
|
||||
/// inventory. Other states (`WASTE`, `UNLINKED_RETURN`, etc.) are ignored
|
||||
/// when converting to a domain quantity.
|
||||
class SquareInventoryMapper {
|
||||
const SquareInventoryMapper();
|
||||
|
||||
// ── Square InventoryCount → ChannelInventoryCount ─────────────────────────
|
||||
|
||||
/// Maps a single Square InventoryCount [json] map to a
|
||||
/// [ChannelInventoryCount].
|
||||
///
|
||||
/// Returns `null` if the count is not in `IN_STOCK` state or is missing
|
||||
/// required fields.
|
||||
ChannelInventoryCount? fromSquareInventoryCount(Map<String, dynamic> json) {
|
||||
final catalogObjectId = json['catalog_object_id'] as String?;
|
||||
if (catalogObjectId == null || catalogObjectId.isEmpty) return null;
|
||||
|
||||
// Only map IN_STOCK counts to on-hand quantity.
|
||||
final state = json['state'] as String? ?? '';
|
||||
if (state != 'IN_STOCK') return null;
|
||||
|
||||
final quantityStr = json['quantity'] as String? ?? '0';
|
||||
final quantity = int.tryParse(quantityStr) ?? 0;
|
||||
|
||||
return ChannelInventoryCount(productId: catalogObjectId, quantity: quantity);
|
||||
}
|
||||
|
||||
/// Maps a list of Square InventoryCount [jsonList] maps to
|
||||
/// [ChannelInventoryCount]s, skipping non-IN_STOCK entries and invalid
|
||||
/// records.
|
||||
List<ChannelInventoryCount> fromSquareInventoryCountList(List<Map<String, dynamic>> jsonList) {
|
||||
final results = <ChannelInventoryCount>[];
|
||||
for (final json in jsonList) {
|
||||
final count = fromSquareInventoryCount(json);
|
||||
if (count != null) results.add(count);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── ChannelInventoryCount → Square batch-change entry ────────────────────
|
||||
|
||||
/// Converts a [ChannelInventoryCount] to a Square inventory batch-change
|
||||
/// entry suitable for use with [SquareApiClient.batchChangeInventory].
|
||||
///
|
||||
/// Uses `PHYSICAL_COUNT` type to set an absolute quantity (not a delta).
|
||||
///
|
||||
/// [locationId] is the Square location ID for the count.
|
||||
/// [occurredAt] defaults to the current UTC time in RFC 3339 format.
|
||||
Map<String, dynamic> toSquareBatchChangeEntry(
|
||||
ChannelInventoryCount count, {
|
||||
required String locationId,
|
||||
DateTime? occurredAt,
|
||||
}) {
|
||||
final timestamp = (occurredAt ?? DateTime.now().toUtc()).toIso8601String();
|
||||
|
||||
return {
|
||||
'type': 'PHYSICAL_COUNT',
|
||||
'physical_count': {
|
||||
'catalog_object_id': count.productId,
|
||||
'state': 'IN_STOCK',
|
||||
'location_id': locationId,
|
||||
'quantity': count.quantity.toString(),
|
||||
'occurred_at': timestamp,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Converts a list of [ChannelInventoryCount]s to Square batch-change
|
||||
/// entries.
|
||||
List<Map<String, dynamic>> toSquareBatchChangeEntries(
|
||||
List<ChannelInventoryCount> counts, {
|
||||
required String locationId,
|
||||
DateTime? occurredAt,
|
||||
}) {
|
||||
return counts
|
||||
.map((c) => toSquareBatchChangeEntry(c, locationId: locationId, occurredAt: occurredAt))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import 'channel_adapter.dart';
|
||||
|
||||
/// Maps between Square CatalogObject JSON maps and the channel-neutral
|
||||
/// [ChannelProduct] representation.
|
||||
///
|
||||
/// Square catalog items have a nested structure:
|
||||
/// ```
|
||||
/// {
|
||||
/// "type": "ITEM",
|
||||
/// "id": "SQ-ITEM-001",
|
||||
/// "item_data": {
|
||||
/// "name": "Floral Bowl Cozy",
|
||||
/// "description": "...",
|
||||
/// "variations": [
|
||||
/// {
|
||||
/// "type": "ITEM_VARIATION",
|
||||
/// "id": "SQ-VAR-001",
|
||||
/// "item_variation_data": {
|
||||
/// "name": "Regular",
|
||||
/// "price_money": { "amount": 1299, "currency": "USD" },
|
||||
/// "sku": "BC-FLR-001"
|
||||
/// }
|
||||
/// }
|
||||
/// ],
|
||||
/// "category_id": "SQ-CAT-001"
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Price is stored in **cents** by Square; this mapper converts to/from
|
||||
/// dollars (double) for the domain layer.
|
||||
///
|
||||
/// Only the first variation is used for price and SKU when mapping from
|
||||
/// Square → domain. When mapping domain → Square, a single default variation
|
||||
/// is generated.
|
||||
class SquareProductMapper {
|
||||
const SquareProductMapper();
|
||||
|
||||
// ── Square → ChannelProduct ───────────────────────────────────────────────
|
||||
|
||||
/// Maps a Square CatalogObject [json] map (type `ITEM`) to a
|
||||
/// [ChannelProduct].
|
||||
///
|
||||
/// Returns `null` if the object is not a valid ITEM type or is missing
|
||||
/// required fields.
|
||||
ChannelProduct? fromSquareCatalogObject(Map<String, dynamic> json) {
|
||||
if (json['type'] != 'ITEM') return null;
|
||||
|
||||
final id = json['id'] as String?;
|
||||
if (id == null || id.isEmpty) return null;
|
||||
|
||||
final itemData = json['item_data'];
|
||||
if (itemData is! Map<String, dynamic>) return null;
|
||||
|
||||
final name = itemData['name'] as String? ?? '';
|
||||
final description = itemData['description'] as String?;
|
||||
|
||||
// Extract price and SKU from the first variation.
|
||||
double price = 0.0;
|
||||
String? sku;
|
||||
final variations = itemData['variations'];
|
||||
if (variations is List && variations.isNotEmpty) {
|
||||
final firstVariation = variations.first;
|
||||
if (firstVariation is Map<String, dynamic>) {
|
||||
final varData = firstVariation['item_variation_data'];
|
||||
if (varData is Map<String, dynamic>) {
|
||||
sku = varData['sku'] as String?;
|
||||
final priceMoney = varData['price_money'];
|
||||
if (priceMoney is Map<String, dynamic>) {
|
||||
final amountCents = priceMoney['amount'];
|
||||
if (amountCents is int) {
|
||||
price = amountCents / 100.0;
|
||||
} else if (amountCents is double) {
|
||||
price = amountCents / 100.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map Square present_at_all_locations / is_deleted to a status string.
|
||||
final isDeleted = json['is_deleted'] as bool? ?? false;
|
||||
final status = isDeleted ? 'archived' : 'active';
|
||||
|
||||
return ChannelProduct(
|
||||
id: id,
|
||||
name: name,
|
||||
price: price,
|
||||
status: status,
|
||||
description: description,
|
||||
sku: sku,
|
||||
);
|
||||
}
|
||||
|
||||
/// Maps a list of Square CatalogObject [jsonList] maps to [ChannelProduct]s,
|
||||
/// skipping any entries that cannot be parsed.
|
||||
List<ChannelProduct> fromSquareCatalogList(List<Map<String, dynamic>> jsonList) {
|
||||
final results = <ChannelProduct>[];
|
||||
for (final json in jsonList) {
|
||||
final product = fromSquareCatalogObject(json);
|
||||
if (product != null) results.add(product);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── ChannelProduct → Square CatalogObject ────────────────────────────────
|
||||
|
||||
/// Converts a [ChannelProduct] to a Square CatalogObject map suitable for
|
||||
/// use with [SquareApiClient.upsertCatalogItem].
|
||||
///
|
||||
/// [squareItemId] is the existing Square ITEM object ID for updates, or a
|
||||
/// temporary ID (e.g. `'#new-item'`) for creates.
|
||||
///
|
||||
/// [squareVariationId] is the existing variation ID for updates, or a
|
||||
/// temporary ID (e.g. `'#new-variation'`) for creates.
|
||||
///
|
||||
/// [locationId] is required by Square for variation pricing.
|
||||
Map<String, dynamic> toSquareCatalogObject(
|
||||
ChannelProduct product, {
|
||||
String squareItemId = '#new-item',
|
||||
String squareVariationId = '#new-variation',
|
||||
required String locationId,
|
||||
}) {
|
||||
final amountCents = (product.price * 100).round();
|
||||
|
||||
final variationData = <String, dynamic>{
|
||||
'name': 'Regular',
|
||||
'pricing_type': 'FIXED_PRICING',
|
||||
'price_money': {'amount': amountCents, 'currency': 'USD'},
|
||||
'location_overrides': [
|
||||
{'location_id': locationId, 'track_inventory': true},
|
||||
],
|
||||
};
|
||||
if (product.sku != null && product.sku!.isNotEmpty) {
|
||||
variationData['sku'] = product.sku;
|
||||
}
|
||||
|
||||
final itemData = <String, dynamic>{
|
||||
'name': product.name,
|
||||
'variations': [
|
||||
{'type': 'ITEM_VARIATION', 'id': squareVariationId, 'item_variation_data': variationData},
|
||||
],
|
||||
};
|
||||
if (product.description != null && product.description!.isNotEmpty) {
|
||||
itemData['description'] = product.description;
|
||||
}
|
||||
|
||||
return {'type': 'ITEM', 'id': squareItemId, 'item_data': itemData};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,593 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integrations/integrations.dart';
|
||||
|
||||
void main() {
|
||||
// ── SquareProductMapper ────────────────────────────────────────────────────
|
||||
|
||||
group('SquareProductMapper', () {
|
||||
const mapper = SquareProductMapper();
|
||||
|
||||
// ── fromSquareCatalogObject ──────────────────────────────────────────────
|
||||
|
||||
group('fromSquareCatalogObject', () {
|
||||
test('maps a full ITEM object to ChannelProduct', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-001',
|
||||
'item_data': {
|
||||
'name': 'Floral Bowl Cozy',
|
||||
'description': 'A beautifully crafted fabric bowl cozy.',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-001',
|
||||
'item_variation_data': {
|
||||
'name': 'Regular',
|
||||
'sku': 'BC-FLR-001',
|
||||
'price_money': {'amount': 1299, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
|
||||
expect(product, isNotNull);
|
||||
expect(product!.id, 'SQ-ITEM-001');
|
||||
expect(product.name, 'Floral Bowl Cozy');
|
||||
expect(product.description, 'A beautifully crafted fabric bowl cozy.');
|
||||
expect(product.price, closeTo(12.99, 0.001));
|
||||
expect(product.sku, 'BC-FLR-001');
|
||||
expect(product.status, 'active');
|
||||
});
|
||||
|
||||
test('maps price in cents to dollars correctly', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-002',
|
||||
'item_data': {
|
||||
'name': 'Coaster Set',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-002',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 1650, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
expect(product!.price, closeTo(16.50, 0.001));
|
||||
});
|
||||
|
||||
test('returns null for non-ITEM type', () {
|
||||
final json = {
|
||||
'type': 'CATEGORY',
|
||||
'id': 'SQ-CAT-001',
|
||||
'category_data': {'name': 'Kitchen'},
|
||||
};
|
||||
|
||||
expect(mapper.fromSquareCatalogObject(json), isNull);
|
||||
});
|
||||
|
||||
test('returns null when id is missing', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'item_data': {'name': 'No ID Item'},
|
||||
};
|
||||
|
||||
expect(mapper.fromSquareCatalogObject(json), isNull);
|
||||
});
|
||||
|
||||
test('returns null when item_data is missing', () {
|
||||
final json = {'type': 'ITEM', 'id': 'SQ-ITEM-003'};
|
||||
|
||||
expect(mapper.fromSquareCatalogObject(json), isNull);
|
||||
});
|
||||
|
||||
test('maps is_deleted=true to archived status', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-004',
|
||||
'is_deleted': true,
|
||||
'item_data': {
|
||||
'name': 'Archived Item',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-004',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 999, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
expect(product!.status, 'archived');
|
||||
});
|
||||
|
||||
test('defaults price to 0.0 when no variations present', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-005',
|
||||
'item_data': {'name': 'No Price Item', 'variations': []},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
expect(product!.price, 0.0);
|
||||
});
|
||||
|
||||
test('defaults price to 0.0 when price_money is absent', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-006',
|
||||
'item_data': {
|
||||
'name': 'No Price Money',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-006',
|
||||
'item_variation_data': {'name': 'Regular'},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
expect(product!.price, 0.0);
|
||||
});
|
||||
|
||||
test('sku is null when not present in variation', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-007',
|
||||
'item_data': {
|
||||
'name': 'No SKU Item',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-007',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 500, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
expect(product!.sku, isNull);
|
||||
});
|
||||
|
||||
test('description is null when not present in item_data', () {
|
||||
final json = {
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-008',
|
||||
'item_data': {
|
||||
'name': 'No Description',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-008',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 800, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
final product = mapper.fromSquareCatalogObject(json);
|
||||
expect(product!.description, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ── fromSquareCatalogList ────────────────────────────────────────────────
|
||||
|
||||
group('fromSquareCatalogList', () {
|
||||
test('maps a list of ITEM objects', () {
|
||||
final jsonList = [
|
||||
{
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-A',
|
||||
'item_data': {
|
||||
'name': 'Item A',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-A',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 1000, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-B',
|
||||
'item_data': {
|
||||
'name': 'Item B',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-B',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 2000, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
final products = mapper.fromSquareCatalogList(jsonList);
|
||||
expect(products.length, 2);
|
||||
expect(products[0].id, 'SQ-ITEM-A');
|
||||
expect(products[1].id, 'SQ-ITEM-B');
|
||||
});
|
||||
|
||||
test('skips non-ITEM entries in a mixed list', () {
|
||||
final jsonList = [
|
||||
{
|
||||
'type': 'ITEM',
|
||||
'id': 'SQ-ITEM-A',
|
||||
'item_data': {
|
||||
'name': 'Item A',
|
||||
'variations': [
|
||||
{
|
||||
'type': 'ITEM_VARIATION',
|
||||
'id': 'SQ-VAR-A',
|
||||
'item_variation_data': {
|
||||
'price_money': {'amount': 1000, 'currency': 'USD'},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{'type': 'CATEGORY', 'id': 'SQ-CAT-001', 'category_data': {}},
|
||||
{'type': 'TAX', 'id': 'SQ-TAX-001'},
|
||||
];
|
||||
|
||||
final products = mapper.fromSquareCatalogList(jsonList);
|
||||
expect(products.length, 1);
|
||||
expect(products.first.id, 'SQ-ITEM-A');
|
||||
});
|
||||
|
||||
test('returns empty list for empty input', () {
|
||||
expect(mapper.fromSquareCatalogList([]), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── toSquareCatalogObject ────────────────────────────────────────────────
|
||||
|
||||
group('toSquareCatalogObject', () {
|
||||
test('converts ChannelProduct to Square CatalogObject', () {
|
||||
const product = ChannelProduct(
|
||||
id: 'local-1',
|
||||
name: 'Floral Bowl Cozy',
|
||||
price: 12.99,
|
||||
status: 'active',
|
||||
description: 'A fabric bowl cozy.',
|
||||
sku: 'BC-FLR-001',
|
||||
);
|
||||
|
||||
final json = mapper.toSquareCatalogObject(
|
||||
product,
|
||||
squareItemId: 'SQ-ITEM-001',
|
||||
squareVariationId: 'SQ-VAR-001',
|
||||
locationId: 'LOC123',
|
||||
);
|
||||
|
||||
expect(json['type'], 'ITEM');
|
||||
expect(json['id'], 'SQ-ITEM-001');
|
||||
|
||||
final itemData = json['item_data'] as Map<String, dynamic>;
|
||||
expect(itemData['name'], 'Floral Bowl Cozy');
|
||||
expect(itemData['description'], 'A fabric bowl cozy.');
|
||||
|
||||
final variations = itemData['variations'] as List;
|
||||
expect(variations.length, 1);
|
||||
|
||||
final variation = variations.first as Map<String, dynamic>;
|
||||
expect(variation['type'], 'ITEM_VARIATION');
|
||||
expect(variation['id'], 'SQ-VAR-001');
|
||||
|
||||
final varData = variation['item_variation_data'] as Map<String, dynamic>;
|
||||
expect(varData['sku'], 'BC-FLR-001');
|
||||
|
||||
final priceMoney = varData['price_money'] as Map<String, dynamic>;
|
||||
expect(priceMoney['amount'], 1299);
|
||||
expect(priceMoney['currency'], 'USD');
|
||||
});
|
||||
|
||||
test('converts price to cents correctly', () {
|
||||
const product = ChannelProduct(
|
||||
id: 'local-2',
|
||||
name: 'Coaster Set',
|
||||
price: 16.50,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
|
||||
final itemData = json['item_data'] as Map<String, dynamic>;
|
||||
final variations = itemData['variations'] as List;
|
||||
final varData =
|
||||
(variations.first as Map<String, dynamic>)['item_variation_data']
|
||||
as Map<String, dynamic>;
|
||||
final priceMoney = varData['price_money'] as Map<String, dynamic>;
|
||||
|
||||
expect(priceMoney['amount'], 1650);
|
||||
});
|
||||
|
||||
test('uses default temporary IDs when not provided', () {
|
||||
const product = ChannelProduct(
|
||||
id: 'local-3',
|
||||
name: 'New Item',
|
||||
price: 9.99,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
|
||||
expect(json['id'], '#new-item');
|
||||
|
||||
final itemData = json['item_data'] as Map<String, dynamic>;
|
||||
final variations = itemData['variations'] as List;
|
||||
expect((variations.first as Map<String, dynamic>)['id'], '#new-variation');
|
||||
});
|
||||
|
||||
test('omits description when null', () {
|
||||
const product = ChannelProduct(
|
||||
id: 'local-4',
|
||||
name: 'No Description',
|
||||
price: 5.00,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
|
||||
final itemData = json['item_data'] as Map<String, dynamic>;
|
||||
expect(itemData.containsKey('description'), isFalse);
|
||||
});
|
||||
|
||||
test('omits sku from variation when null', () {
|
||||
const product = ChannelProduct(
|
||||
id: 'local-5',
|
||||
name: 'No SKU',
|
||||
price: 5.00,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC123');
|
||||
final itemData = json['item_data'] as Map<String, dynamic>;
|
||||
final variations = itemData['variations'] as List;
|
||||
final varData =
|
||||
(variations.first as Map<String, dynamic>)['item_variation_data']
|
||||
as Map<String, dynamic>;
|
||||
expect(varData.containsKey('sku'), isFalse);
|
||||
});
|
||||
|
||||
test('includes location_overrides with track_inventory', () {
|
||||
const product = ChannelProduct(
|
||||
id: 'local-6',
|
||||
name: 'Track Me',
|
||||
price: 10.00,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
final json = mapper.toSquareCatalogObject(product, locationId: 'LOC456');
|
||||
final itemData = json['item_data'] as Map<String, dynamic>;
|
||||
final variations = itemData['variations'] as List;
|
||||
final varData =
|
||||
(variations.first as Map<String, dynamic>)['item_variation_data']
|
||||
as Map<String, dynamic>;
|
||||
final overrides = varData['location_overrides'] as List;
|
||||
|
||||
expect(overrides.length, 1);
|
||||
final override = overrides.first as Map<String, dynamic>;
|
||||
expect(override['location_id'], 'LOC456');
|
||||
expect(override['track_inventory'], isTrue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── SquareInventoryMapper ──────────────────────────────────────────────────
|
||||
|
||||
group('SquareInventoryMapper', () {
|
||||
const mapper = SquareInventoryMapper();
|
||||
|
||||
// ── fromSquareInventoryCount ─────────────────────────────────────────────
|
||||
|
||||
group('fromSquareInventoryCount', () {
|
||||
test('maps a valid IN_STOCK count to ChannelInventoryCount', () {
|
||||
final json = {
|
||||
'catalog_object_id': 'SQ-VAR-001',
|
||||
'catalog_object_type': 'ITEM_VARIATION',
|
||||
'state': 'IN_STOCK',
|
||||
'location_id': 'LOC123',
|
||||
'quantity': '18',
|
||||
'calculated_at': '2026-07-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
final count = mapper.fromSquareInventoryCount(json);
|
||||
|
||||
expect(count, isNotNull);
|
||||
expect(count!.productId, 'SQ-VAR-001');
|
||||
expect(count.quantity, 18);
|
||||
});
|
||||
|
||||
test('returns null for non-IN_STOCK state', () {
|
||||
final json = {'catalog_object_id': 'SQ-VAR-001', 'state': 'WASTE', 'quantity': '5'};
|
||||
|
||||
expect(mapper.fromSquareInventoryCount(json), isNull);
|
||||
});
|
||||
|
||||
test('returns null when catalog_object_id is missing', () {
|
||||
final json = {'state': 'IN_STOCK', 'quantity': '10'};
|
||||
|
||||
expect(mapper.fromSquareInventoryCount(json), isNull);
|
||||
});
|
||||
|
||||
test('returns null when catalog_object_id is empty string', () {
|
||||
final json = {'catalog_object_id': '', 'state': 'IN_STOCK', 'quantity': '10'};
|
||||
|
||||
expect(mapper.fromSquareInventoryCount(json), isNull);
|
||||
});
|
||||
|
||||
test('defaults quantity to 0 when quantity field is missing', () {
|
||||
final json = {'catalog_object_id': 'SQ-VAR-002', 'state': 'IN_STOCK'};
|
||||
|
||||
final count = mapper.fromSquareInventoryCount(json);
|
||||
expect(count!.quantity, 0);
|
||||
});
|
||||
|
||||
test('defaults quantity to 0 when quantity is not parseable', () {
|
||||
final json = {
|
||||
'catalog_object_id': 'SQ-VAR-003',
|
||||
'state': 'IN_STOCK',
|
||||
'quantity': 'not-a-number',
|
||||
};
|
||||
|
||||
final count = mapper.fromSquareInventoryCount(json);
|
||||
expect(count!.quantity, 0);
|
||||
});
|
||||
|
||||
test('parses quantity of zero correctly', () {
|
||||
final json = {'catalog_object_id': 'SQ-VAR-004', 'state': 'IN_STOCK', 'quantity': '0'};
|
||||
|
||||
final count = mapper.fromSquareInventoryCount(json);
|
||||
expect(count!.quantity, 0);
|
||||
});
|
||||
|
||||
test('returns null for UNLINKED_RETURN state', () {
|
||||
final json = {
|
||||
'catalog_object_id': 'SQ-VAR-005',
|
||||
'state': 'UNLINKED_RETURN',
|
||||
'quantity': '3',
|
||||
};
|
||||
|
||||
expect(mapper.fromSquareInventoryCount(json), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ── fromSquareInventoryCountList ─────────────────────────────────────────
|
||||
|
||||
group('fromSquareInventoryCountList', () {
|
||||
test('maps a list of IN_STOCK counts', () {
|
||||
final jsonList = [
|
||||
{'catalog_object_id': 'SQ-VAR-A', 'state': 'IN_STOCK', 'quantity': '10'},
|
||||
{'catalog_object_id': 'SQ-VAR-B', 'state': 'IN_STOCK', 'quantity': '5'},
|
||||
];
|
||||
|
||||
final counts = mapper.fromSquareInventoryCountList(jsonList);
|
||||
expect(counts.length, 2);
|
||||
expect(counts[0].productId, 'SQ-VAR-A');
|
||||
expect(counts[0].quantity, 10);
|
||||
expect(counts[1].productId, 'SQ-VAR-B');
|
||||
expect(counts[1].quantity, 5);
|
||||
});
|
||||
|
||||
test('skips non-IN_STOCK entries in a mixed list', () {
|
||||
final jsonList = [
|
||||
{'catalog_object_id': 'SQ-VAR-A', 'state': 'IN_STOCK', 'quantity': '10'},
|
||||
{'catalog_object_id': 'SQ-VAR-B', 'state': 'WASTE', 'quantity': '2'},
|
||||
{'catalog_object_id': 'SQ-VAR-C', 'state': 'IN_STOCK', 'quantity': '7'},
|
||||
];
|
||||
|
||||
final counts = mapper.fromSquareInventoryCountList(jsonList);
|
||||
expect(counts.length, 2);
|
||||
expect(counts.map((c) => c.productId), containsAll(['SQ-VAR-A', 'SQ-VAR-C']));
|
||||
});
|
||||
|
||||
test('returns empty list for empty input', () {
|
||||
expect(mapper.fromSquareInventoryCountList([]), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ── toSquareBatchChangeEntry ─────────────────────────────────────────────
|
||||
|
||||
group('toSquareBatchChangeEntry', () {
|
||||
test('converts ChannelInventoryCount to PHYSICAL_COUNT entry', () {
|
||||
const count = ChannelInventoryCount(productId: 'SQ-VAR-001', quantity: 18);
|
||||
final fixedTime = DateTime.utc(2026, 7, 1, 12, 0, 0);
|
||||
|
||||
final entry = mapper.toSquareBatchChangeEntry(
|
||||
count,
|
||||
locationId: 'LOC123',
|
||||
occurredAt: fixedTime,
|
||||
);
|
||||
|
||||
expect(entry['type'], 'PHYSICAL_COUNT');
|
||||
|
||||
final physicalCount = entry['physical_count'] as Map<String, dynamic>;
|
||||
expect(physicalCount['catalog_object_id'], 'SQ-VAR-001');
|
||||
expect(physicalCount['state'], 'IN_STOCK');
|
||||
expect(physicalCount['location_id'], 'LOC123');
|
||||
expect(physicalCount['quantity'], '18');
|
||||
expect(physicalCount['occurred_at'], '2026-07-01T12:00:00.000Z');
|
||||
});
|
||||
|
||||
test('quantity is serialised as a string', () {
|
||||
const count = ChannelInventoryCount(productId: 'SQ-VAR-002', quantity: 0);
|
||||
final entry = mapper.toSquareBatchChangeEntry(
|
||||
count,
|
||||
locationId: 'LOC123',
|
||||
occurredAt: DateTime.utc(2026, 1, 1),
|
||||
);
|
||||
|
||||
final physicalCount = entry['physical_count'] as Map<String, dynamic>;
|
||||
expect(physicalCount['quantity'], isA<String>());
|
||||
expect(physicalCount['quantity'], '0');
|
||||
});
|
||||
|
||||
test('uses current time when occurredAt is not provided', () {
|
||||
const count = ChannelInventoryCount(productId: 'SQ-VAR-003', quantity: 5);
|
||||
final before = DateTime.now().toUtc();
|
||||
final entry = mapper.toSquareBatchChangeEntry(count, locationId: 'LOC123');
|
||||
final after = DateTime.now().toUtc();
|
||||
|
||||
final physicalCount = entry['physical_count'] as Map<String, dynamic>;
|
||||
final timestamp = DateTime.parse(physicalCount['occurred_at'] as String);
|
||||
expect(timestamp.isAfter(before.subtract(const Duration(seconds: 1))), isTrue);
|
||||
expect(timestamp.isBefore(after.add(const Duration(seconds: 1))), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ── toSquareBatchChangeEntries ───────────────────────────────────────────
|
||||
|
||||
group('toSquareBatchChangeEntries', () {
|
||||
test('converts a list of counts to batch-change entries', () {
|
||||
const counts = [
|
||||
ChannelInventoryCount(productId: 'SQ-VAR-A', quantity: 10),
|
||||
ChannelInventoryCount(productId: 'SQ-VAR-B', quantity: 3),
|
||||
];
|
||||
final fixedTime = DateTime.utc(2026, 7, 1);
|
||||
|
||||
final entries = mapper.toSquareBatchChangeEntries(
|
||||
counts,
|
||||
locationId: 'LOC123',
|
||||
occurredAt: fixedTime,
|
||||
);
|
||||
|
||||
expect(entries.length, 2);
|
||||
expect(
|
||||
(entries[0]['physical_count'] as Map<String, dynamic>)['catalog_object_id'],
|
||||
'SQ-VAR-A',
|
||||
);
|
||||
expect(
|
||||
(entries[1]['physical_count'] as Map<String, dynamic>)['catalog_object_id'],
|
||||
'SQ-VAR-B',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns empty list for empty input', () {
|
||||
expect(mapper.toSquareBatchChangeEntries([], locationId: 'LOC123'), isEmpty);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue