@endora-commerce/mod-inventory 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/admin/api/warehouses-client.d.ts +22 -0
- package/dist/admin/api/warehouses-client.d.ts.map +1 -0
- package/dist/admin/api/warehouses-client.js +32 -0
- package/dist/admin/api/warehouses-client.js.map +1 -0
- package/dist/admin/index.d.ts +43 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +190 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/AvailabilityNotificationsPage.d.ts +15 -0
- package/dist/admin/pages/AvailabilityNotificationsPage.d.ts.map +1 -0
- package/dist/admin/pages/AvailabilityNotificationsPage.js +111 -0
- package/dist/admin/pages/AvailabilityNotificationsPage.js.map +1 -0
- package/dist/admin/pages/InventoryPage.d.ts +17 -0
- package/dist/admin/pages/InventoryPage.d.ts.map +1 -0
- package/dist/admin/pages/InventoryPage.js +135 -0
- package/dist/admin/pages/InventoryPage.js.map +1 -0
- package/dist/admin/pages/LowStockPage.d.ts +16 -0
- package/dist/admin/pages/LowStockPage.d.ts.map +1 -0
- package/dist/admin/pages/LowStockPage.js +61 -0
- package/dist/admin/pages/LowStockPage.js.map +1 -0
- package/dist/admin/pages/StockImportWizard.d.ts +17 -0
- package/dist/admin/pages/StockImportWizard.d.ts.map +1 -0
- package/dist/admin/pages/StockImportWizard.js +111 -0
- package/dist/admin/pages/StockImportWizard.js.map +1 -0
- package/dist/admin/pages/WarehouseEditor.d.ts +12 -0
- package/dist/admin/pages/WarehouseEditor.d.ts.map +1 -0
- package/dist/admin/pages/WarehouseEditor.js +194 -0
- package/dist/admin/pages/WarehouseEditor.js.map +1 -0
- package/dist/admin/pages/WarehousesList.d.ts +12 -0
- package/dist/admin/pages/WarehousesList.d.ts.map +1 -0
- package/dist/admin/pages/WarehousesList.js +69 -0
- package/dist/admin/pages/WarehousesList.js.map +1 -0
- package/dist/admin/zones/ChannelWarehouses.d.ts +49 -0
- package/dist/admin/zones/ChannelWarehouses.d.ts.map +1 -0
- package/dist/admin/zones/ChannelWarehouses.js +109 -0
- package/dist/admin/zones/ChannelWarehouses.js.map +1 -0
- package/dist/backend/demo/reset.d.ts +18 -0
- package/dist/backend/demo/reset.d.ts.map +1 -0
- package/dist/backend/demo/reset.js +20 -0
- package/dist/backend/demo/reset.js.map +1 -0
- package/dist/backend/demo/rows.d.ts +38 -0
- package/dist/backend/demo/rows.d.ts.map +1 -0
- package/dist/backend/demo/rows.js +38 -0
- package/dist/backend/demo/rows.js.map +1 -0
- package/dist/backend/demo/seed.d.ts +26 -0
- package/dist/backend/demo/seed.d.ts.map +1 -0
- package/dist/backend/demo/seed.js +65 -0
- package/dist/backend/demo/seed.js.map +1 -0
- package/dist/backend/email-templates/transactional-defaults.d.ts +11 -0
- package/dist/backend/email-templates/transactional-defaults.d.ts.map +1 -0
- package/dist/backend/email-templates/transactional-defaults.js +49 -0
- package/dist/backend/email-templates/transactional-defaults.js.map +1 -0
- package/dist/backend/entities/availability-notification.entity.d.ts +49 -0
- package/dist/backend/entities/availability-notification.entity.d.ts.map +1 -0
- package/dist/backend/entities/availability-notification.entity.js +93 -0
- package/dist/backend/entities/availability-notification.entity.js.map +1 -0
- package/dist/backend/entities/inventory-threshold.entity.d.ts +24 -0
- package/dist/backend/entities/inventory-threshold.entity.d.ts.map +1 -0
- package/dist/backend/entities/inventory-threshold.entity.js +61 -0
- package/dist/backend/entities/inventory-threshold.entity.js.map +1 -0
- package/dist/backend/entities/product-warehouse-low-stock-threshold.entity.d.ts +15 -0
- package/dist/backend/entities/product-warehouse-low-stock-threshold.entity.d.ts.map +1 -0
- package/dist/backend/entities/product-warehouse-low-stock-threshold.entity.js +51 -0
- package/dist/backend/entities/product-warehouse-low-stock-threshold.entity.js.map +1 -0
- package/dist/backend/entities/stock-allocation.entity.d.ts +22 -0
- package/dist/backend/entities/stock-allocation.entity.d.ts.map +1 -0
- package/dist/backend/entities/stock-allocation.entity.js +69 -0
- package/dist/backend/entities/stock-allocation.entity.js.map +1 -0
- package/dist/backend/entities/stock-level.entity.d.ts +24 -0
- package/dist/backend/entities/stock-level.entity.d.ts.map +1 -0
- package/dist/backend/entities/stock-level.entity.js +74 -0
- package/dist/backend/entities/stock-level.entity.js.map +1 -0
- package/dist/backend/entities/warehouse-channel-assignment.entity.d.ts +17 -0
- package/dist/backend/entities/warehouse-channel-assignment.entity.d.ts.map +1 -0
- package/dist/backend/entities/warehouse-channel-assignment.entity.js +60 -0
- package/dist/backend/entities/warehouse-channel-assignment.entity.js.map +1 -0
- package/dist/backend/entities/warehouse.entity.d.ts +37 -0
- package/dist/backend/entities/warehouse.entity.d.ts.map +1 -0
- package/dist/backend/entities/warehouse.entity.js +90 -0
- package/dist/backend/entities/warehouse.entity.js.map +1 -0
- package/dist/backend/index.d.ts +139 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +351 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/plugin.d.ts +113 -0
- package/dist/backend/plugin.d.ts.map +1 -0
- package/dist/backend/plugin.js +75 -0
- package/dist/backend/plugin.js.map +1 -0
- package/dist/backend/prompt-tools.d.ts +23 -0
- package/dist/backend/prompt-tools.d.ts.map +1 -0
- package/dist/backend/prompt-tools.js +77 -0
- package/dist/backend/prompt-tools.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +62 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +383 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.d.ts +40 -0
- package/dist/backend/routes.d.ts.map +1 -0
- package/dist/backend/routes.js +263 -0
- package/dist/backend/routes.js.map +1 -0
- package/dist/backend/services/audit-references.d.ts +15 -0
- package/dist/backend/services/audit-references.d.ts.map +1 -0
- package/dist/backend/services/audit-references.js +27 -0
- package/dist/backend/services/audit-references.js.map +1 -0
- package/dist/backend/services/availability-notification-service.d.ts +155 -0
- package/dist/backend/services/availability-notification-service.d.ts.map +1 -0
- package/dist/backend/services/availability-notification-service.js +363 -0
- package/dist/backend/services/availability-notification-service.js.map +1 -0
- package/dist/backend/services/availability-worker.d.ts +57 -0
- package/dist/backend/services/availability-worker.d.ts.map +1 -0
- package/dist/backend/services/availability-worker.js +135 -0
- package/dist/backend/services/availability-worker.js.map +1 -0
- package/dist/backend/services/csv-stock-importer.d.ts +52 -0
- package/dist/backend/services/csv-stock-importer.d.ts.map +1 -0
- package/dist/backend/services/csv-stock-importer.js +154 -0
- package/dist/backend/services/csv-stock-importer.js.map +1 -0
- package/dist/backend/services/display-band-resolver.d.ts +19 -0
- package/dist/backend/services/display-band-resolver.d.ts.map +1 -0
- package/dist/backend/services/display-band-resolver.js +21 -0
- package/dist/backend/services/display-band-resolver.js.map +1 -0
- package/dist/backend/services/effective-fulfilment-strategy.d.ts +16 -0
- package/dist/backend/services/effective-fulfilment-strategy.d.ts.map +1 -0
- package/dist/backend/services/effective-fulfilment-strategy.js +16 -0
- package/dist/backend/services/effective-fulfilment-strategy.js.map +1 -0
- package/dist/backend/services/fulfilment-strategy-resolver.d.ts +13 -0
- package/dist/backend/services/fulfilment-strategy-resolver.d.ts.map +1 -0
- package/dist/backend/services/fulfilment-strategy-resolver.js +13 -0
- package/dist/backend/services/fulfilment-strategy-resolver.js.map +1 -0
- package/dist/backend/services/inventory-read-port.d.ts +65 -0
- package/dist/backend/services/inventory-read-port.d.ts.map +1 -0
- package/dist/backend/services/inventory-read-port.js +168 -0
- package/dist/backend/services/inventory-read-port.js.map +1 -0
- package/dist/backend/services/inventory-reservation-apply-port.d.ts +62 -0
- package/dist/backend/services/inventory-reservation-apply-port.d.ts.map +1 -0
- package/dist/backend/services/inventory-reservation-apply-port.js +134 -0
- package/dist/backend/services/inventory-reservation-apply-port.js.map +1 -0
- package/dist/backend/services/low-stock-alert-service.d.ts +87 -0
- package/dist/backend/services/low-stock-alert-service.d.ts.map +1 -0
- package/dist/backend/services/low-stock-alert-service.js +164 -0
- package/dist/backend/services/low-stock-alert-service.js.map +1 -0
- package/dist/backend/services/product-threshold-write.service.d.ts +38 -0
- package/dist/backend/services/product-threshold-write.service.d.ts.map +1 -0
- package/dist/backend/services/product-threshold-write.service.js +80 -0
- package/dist/backend/services/product-threshold-write.service.js.map +1 -0
- package/dist/backend/services/stock-import.service.d.ts +43 -0
- package/dist/backend/services/stock-import.service.d.ts.map +1 -0
- package/dist/backend/services/stock-import.service.js +124 -0
- package/dist/backend/services/stock-import.service.js.map +1 -0
- package/dist/backend/services/stock-level-service.d.ts +146 -0
- package/dist/backend/services/stock-level-service.d.ts.map +1 -0
- package/dist/backend/services/stock-level-service.js +489 -0
- package/dist/backend/services/stock-level-service.js.map +1 -0
- package/dist/backend/services/threshold-admin-service.d.ts +73 -0
- package/dist/backend/services/threshold-admin-service.d.ts.map +1 -0
- package/dist/backend/services/threshold-admin-service.js +167 -0
- package/dist/backend/services/threshold-admin-service.js.map +1 -0
- package/dist/backend/services/threshold-resolver.d.ts +23 -0
- package/dist/backend/services/threshold-resolver.d.ts.map +1 -0
- package/dist/backend/services/threshold-resolver.js +29 -0
- package/dist/backend/services/threshold-resolver.js.map +1 -0
- package/dist/backend/services/threshold-settings-mirror.d.ts +55 -0
- package/dist/backend/services/threshold-settings-mirror.d.ts.map +1 -0
- package/dist/backend/services/threshold-settings-mirror.js +84 -0
- package/dist/backend/services/threshold-settings-mirror.js.map +1 -0
- package/dist/backend/services/warehouse-channel-reconciler.d.ts +19 -0
- package/dist/backend/services/warehouse-channel-reconciler.d.ts.map +1 -0
- package/dist/backend/services/warehouse-channel-reconciler.js +65 -0
- package/dist/backend/services/warehouse-channel-reconciler.js.map +1 -0
- package/dist/backend/services/warehouse-channel-service.d.ts +57 -0
- package/dist/backend/services/warehouse-channel-service.d.ts.map +1 -0
- package/dist/backend/services/warehouse-channel-service.js +205 -0
- package/dist/backend/services/warehouse-channel-service.js.map +1 -0
- package/dist/backend/services/warehouse-country-reference.d.ts +14 -0
- package/dist/backend/services/warehouse-country-reference.d.ts.map +1 -0
- package/dist/backend/services/warehouse-country-reference.js +27 -0
- package/dist/backend/services/warehouse-country-reference.js.map +1 -0
- package/dist/backend/services/warehouse-service.d.ts +86 -0
- package/dist/backend/services/warehouse-service.d.ts.map +1 -0
- package/dist/backend/services/warehouse-service.js +267 -0
- package/dist/backend/services/warehouse-service.js.map +1 -0
- package/dist/manifest.d.ts +219 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +377 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260503T182812_inventory_workflow.d.ts +39 -0
- package/dist/migrations/20260503T182812_inventory_workflow.d.ts.map +1 -0
- package/dist/migrations/20260503T182812_inventory_workflow.js +205 -0
- package/dist/migrations/20260503T182812_inventory_workflow.js.map +1 -0
- package/dist/migrations/20260611T140347_inventory_warehouse_default_low_stock_threshold.d.ts +14 -0
- package/dist/migrations/20260611T140347_inventory_warehouse_default_low_stock_threshold.d.ts.map +1 -0
- package/dist/migrations/20260611T140347_inventory_warehouse_default_low_stock_threshold.js +18 -0
- package/dist/migrations/20260611T140347_inventory_warehouse_default_low_stock_threshold.js.map +1 -0
- package/dist/migrations/20260611T140348_inventory_per_warehouse_low_stock_thresholds.d.ts +28 -0
- package/dist/migrations/20260611T140348_inventory_per_warehouse_low_stock_thresholds.d.ts.map +1 -0
- package/dist/migrations/20260611T140348_inventory_per_warehouse_low_stock_thresholds.js +48 -0
- package/dist/migrations/20260611T140348_inventory_per_warehouse_low_stock_thresholds.js.map +1 -0
- package/dist/migrations/20260818T081243_inventory_stock_allocation_order_item_fk.d.ts +44 -0
- package/dist/migrations/20260818T081243_inventory_stock_allocation_order_item_fk.d.ts.map +1 -0
- package/dist/migrations/20260818T081243_inventory_stock_allocation_order_item_fk.js +81 -0
- package/dist/migrations/20260818T081243_inventory_stock_allocation_order_item_fk.js.map +1 -0
- package/dist/migrations/20260830T182139_inventory_organization_attribution.d.ts +109 -0
- package/dist/migrations/20260830T182139_inventory_organization_attribution.d.ts.map +1 -0
- package/dist/migrations/20260830T182139_inventory_organization_attribution.js +178 -0
- package/dist/migrations/20260830T182139_inventory_organization_attribution.js.map +1 -0
- package/dist/migrations/20260912T125716_inventory_organization_warehouses.d.ts +34 -0
- package/dist/migrations/20260912T125716_inventory_organization_warehouses.d.ts.map +1 -0
- package/dist/migrations/20260912T125716_inventory_organization_warehouses.js +50 -0
- package/dist/migrations/20260912T125716_inventory_organization_warehouses.js.map +1 -0
- package/dist/migrations/index.d.ts +32 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +39 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/ports/index.d.ts +183 -0
- package/dist/ports/index.d.ts.map +1 -0
- package/dist/ports/index.js +2 -0
- package/dist/ports/index.js.map +1 -0
- package/docs/inventory.md +177 -0
- package/i18n/en.json +32 -0
- package/i18n/pl.json +32 -0
- package/package.json +105 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { ANONYMOUS_PRODUCT_AUDIENCE, ERROR_CODES, isProductVisibleTo, } from '@endora-commerce/contracts';
|
|
2
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
3
|
+
import { outOfRequestChannel, } from '@endora-commerce/platform/kernel';
|
|
4
|
+
import { AvailabilityNotification } from '../entities/availability-notification.entity.js';
|
|
5
|
+
import { StockLevel } from '../entities/stock-level.entity.js';
|
|
6
|
+
/**
|
|
7
|
+
* The two attribution columns, written together from one resolved owner.
|
|
8
|
+
*
|
|
9
|
+
* This module has a single write — there is no association path that adopts an
|
|
10
|
+
* anonymous subscription into an account later, which is what makes one
|
|
11
|
+
* function enough here where `pwa` needs both directions of an upsert to go
|
|
12
|
+
* through it.
|
|
13
|
+
*/
|
|
14
|
+
function ownerColumns(owner) {
|
|
15
|
+
return owner.kind === 'customer'
|
|
16
|
+
? { customerAccountId: owner.customerAccountId, organizationId: owner.organizationId }
|
|
17
|
+
: {
|
|
18
|
+
customerAccountId: null,
|
|
19
|
+
// FR-011 — an anonymous subscription belongs to no organisation, and
|
|
20
|
+
// the constraint says nothing about it. `an_recipient_check` is what
|
|
21
|
+
// keeps such a row addressable: its `email` is the recipient. Who it
|
|
22
|
+
// *should* belong to is R-6's open question and is not answered here.
|
|
23
|
+
organizationId: null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* AvailabilityNotificationService — feature 010 / US6 surface.
|
|
28
|
+
*
|
|
29
|
+
* The original foundation 001 service had a single `subscribe(customerId,
|
|
30
|
+
* productId, variantId?)` entry point. Feature 010 extends it for the
|
|
31
|
+
* three customer paths spelled out in spec.md US6:
|
|
32
|
+
*
|
|
33
|
+
* - storefront customer signed-in → email pre-filled from the account
|
|
34
|
+
* - storefront anonymous → caller supplies the email
|
|
35
|
+
* - admin browses + cancels any queued row
|
|
36
|
+
*
|
|
37
|
+
* The restock fan-out lives on `AvailabilityWorker.dispatchForStockIncrease`
|
|
38
|
+
* (already exists from foundation 001); the worker is rewired in US6 to
|
|
39
|
+
* subscribe to `inventory.adjusted.v1` events with `before === 0 &&
|
|
40
|
+
* after > 0` so a stock-bump fans out one email per queued row.
|
|
41
|
+
*/
|
|
42
|
+
export class AvailabilityNotificationService {
|
|
43
|
+
emFactory;
|
|
44
|
+
catalogProducts;
|
|
45
|
+
customerAccounts;
|
|
46
|
+
channelMembership;
|
|
47
|
+
mailer;
|
|
48
|
+
templateEmail;
|
|
49
|
+
constructor(emFactory,
|
|
50
|
+
/** `catalogProductReadPort`, owned by `catalog` (feature 075, Phase C). */
|
|
51
|
+
catalogProducts,
|
|
52
|
+
/**
|
|
53
|
+
* `customerAccountReadPort`, owned by `customer_accounts` (feature 075).
|
|
54
|
+
*
|
|
55
|
+
* Feature 087 / D-187 gives it a second reader: the organisation an owned
|
|
56
|
+
* subscription carries. It was already **required** here before that
|
|
57
|
+
* ruling and stays so — this module has exactly one construction site
|
|
58
|
+
* (`plugin.ts`) and it holds the gated port, so a composition that cannot
|
|
59
|
+
* answer "which organisation owns this account" cannot build the service
|
|
60
|
+
* at all. Nothing had to be argued for; it is recorded because `pwa` had
|
|
61
|
+
* to widen its constructor for the same read and the two modules now sit
|
|
62
|
+
* in the same state.
|
|
63
|
+
*
|
|
64
|
+
* Resolved through `lazyPort`, so a switched-off `customer_accounts`
|
|
65
|
+
* refuses at the call with the 503 `MODULE_DISABLED` envelope rather than
|
|
66
|
+
* at composition time.
|
|
67
|
+
*/
|
|
68
|
+
customerAccounts,
|
|
69
|
+
/**
|
|
70
|
+
* `salesChannelMembershipPort` — the sanctioned bridge accessor
|
|
71
|
+
* (Constitution XII), through which {@link subscribe} asks whether the
|
|
72
|
+
* request's channel publishes the product at all.
|
|
73
|
+
*
|
|
74
|
+
* Required rather than optional, and placed before the two optional
|
|
75
|
+
* transports so a construction site cannot leave it off by accident: an
|
|
76
|
+
* optional channel gate is a composition that silently answers without one,
|
|
77
|
+
* which is precisely the state this parameter was added to end. It is a
|
|
78
|
+
* kernel registration rather than a module's, so it adds no edge to this
|
|
79
|
+
* module's manifest.
|
|
80
|
+
*/
|
|
81
|
+
channelMembership, mailer, templateEmail) {
|
|
82
|
+
this.emFactory = emFactory;
|
|
83
|
+
this.catalogProducts = catalogProducts;
|
|
84
|
+
this.customerAccounts = customerAccounts;
|
|
85
|
+
this.channelMembership = channelMembership;
|
|
86
|
+
this.mailer = mailer;
|
|
87
|
+
this.templateEmail = templateEmail;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The organisation that owns a subscription belonging to this account —
|
|
91
|
+
* feature 087 Group B, D-187.
|
|
92
|
+
*
|
|
93
|
+
* Total, and refuses rather than returning `null`:
|
|
94
|
+
* `customer_accounts.organization_id` is `NOT NULL` (D-178), so an account
|
|
95
|
+
* that resolves always has one, and an account that does not resolve is a
|
|
96
|
+
* caller naming a row that is not there. Either way there is no organisation
|
|
97
|
+
* to stamp, and a subscription written without one is a buyer hidden from
|
|
98
|
+
* the representative who serves them — which is the whole defect this column
|
|
99
|
+
* exists to close. So the write stops here, where the message can name the
|
|
100
|
+
* account, instead of at the constraint.
|
|
101
|
+
*/
|
|
102
|
+
async #organizationOf(customerAccountId) {
|
|
103
|
+
const account = await this.customerAccounts.findById(customerAccountId);
|
|
104
|
+
if (!account) {
|
|
105
|
+
throw new Error(`AvailabilityNotificationService: customer account ${customerAccountId} does not resolve, ` +
|
|
106
|
+
'so the availability notification it would own has no organisation to carry.');
|
|
107
|
+
}
|
|
108
|
+
return account.organizationId;
|
|
109
|
+
}
|
|
110
|
+
/** The caller's owner input, with the organisation a customer owner implies. */
|
|
111
|
+
async #resolveOwner(customerAccountId) {
|
|
112
|
+
if (customerAccountId === null || customerAccountId === undefined)
|
|
113
|
+
return { kind: 'anonymous' };
|
|
114
|
+
return {
|
|
115
|
+
kind: 'customer',
|
|
116
|
+
customerAccountId,
|
|
117
|
+
organizationId: await this.#organizationOf(customerAccountId),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* May this caller acquire this product at all — the two filters every
|
|
122
|
+
* product acquisition seam owes (issue #227, issue #259).
|
|
123
|
+
*
|
|
124
|
+
* Both are asked through the shared machinery rather than restated here:
|
|
125
|
+
* `isProductVisibleTo` is the platform's one answer to "may this audience see
|
|
126
|
+
* this product", and {@link outOfRequestChannel} is the channel half, which
|
|
127
|
+
* that predicate says in writing it is not. The channel is read off the
|
|
128
|
+
* request scope through `salesChannelMembershipPort` — the sanctioned bridge
|
|
129
|
+
* accessor — and never re-resolved here; outside a request (a worker, a CLI,
|
|
130
|
+
* a fixture calling the service directly) there is no channel to be out of
|
|
131
|
+
* and the gate answers `false`.
|
|
132
|
+
*
|
|
133
|
+
* The caller gets **one** answer for "no", and the reason is the whole point:
|
|
134
|
+
* the three states this can refuse — the row is not there, the operator
|
|
135
|
+
* restricted it to another organisation, another channel sells it — must be
|
|
136
|
+
* one response, or an unauthenticated caller can sort product ids by reading
|
|
137
|
+
* which refusal came back. The later gates (`PRODUCT_UNMANAGED_STOCK`,
|
|
138
|
+
* `PRODUCT_IN_STOCK`) do discriminate, deliberately and harmlessly: they are
|
|
139
|
+
* reached only for a product this caller may already see in the catalogue,
|
|
140
|
+
* whose existence and stock band that catalogue answers anyway.
|
|
141
|
+
*/
|
|
142
|
+
async #mayAcquire(product, audience) {
|
|
143
|
+
if (!isProductVisibleTo(product, audience ?? ANONYMOUS_PRODUCT_AUDIENCE))
|
|
144
|
+
return false;
|
|
145
|
+
return !(await outOfRequestChannel(this.channelMembership, product.id));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Subscribe a customer / anonymous email to a product's restock signal.
|
|
149
|
+
*
|
|
150
|
+
* Refuses with 404 PRODUCT_NOT_FOUND for every product this caller may not
|
|
151
|
+
* acquire — see {@link #mayAcquire}; with 422 PRODUCT_IN_STOCK when
|
|
152
|
+
* cumulative on-hand > 0; with 409 PRODUCT_UNMANAGED_STOCK when the product
|
|
153
|
+
* opted out of stock tracking; and idempotently returns the existing row on a
|
|
154
|
+
* re-subscribe.
|
|
155
|
+
*/
|
|
156
|
+
async subscribe(input) {
|
|
157
|
+
// command-coverage-ignore: customer back-in-stock notification opt-in — a
|
|
158
|
+
// self-service subscription record, not an audited domain-state mutation.
|
|
159
|
+
const em = this.emFactory();
|
|
160
|
+
const product = await this.catalogProducts.findById(input.productId, { liveOnly: true });
|
|
161
|
+
// Asking to be told when a product returns is an **acquisition**: the
|
|
162
|
+
// caller names a product id they chose, the platform stores a row against
|
|
163
|
+
// it, and answers later with an e-mail naming it. So the two gates every
|
|
164
|
+
// other acquisition seam applies are applied here, and all three states —
|
|
165
|
+
// absent, restricted, sold on another channel — leave through the one
|
|
166
|
+
// refusal below.
|
|
167
|
+
if (!product || !(await this.#mayAcquire(product, input.audience))) {
|
|
168
|
+
throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, 'Product not found.');
|
|
169
|
+
}
|
|
170
|
+
if (!product.manageStock) {
|
|
171
|
+
throw new HttpError(409, 'PRODUCT_UNMANAGED_STOCK', 'Product does not track stock; subscribe is unnecessary.');
|
|
172
|
+
}
|
|
173
|
+
// Cumulative on-hand across every warehouse — the subscribe is gated
|
|
174
|
+
// platform-wide, not per channel.
|
|
175
|
+
// `em.execute`, not `em.getKnex()`: a knex handle carries no transaction
|
|
176
|
+
// context, so this in-stock gate would answer from outside a transaction the
|
|
177
|
+
// caller holds open while the `em.findOne` below answers from inside it
|
|
178
|
+
// (issue #207).
|
|
179
|
+
const sumRows = (await em.execute(`select sum(on_hand) as on_hand from stock_levels where product_id = ?`, [input.productId]));
|
|
180
|
+
const cumulative = Number(sumRows[0]?.on_hand ?? 0);
|
|
181
|
+
if (cumulative > 0) {
|
|
182
|
+
throw new HttpError(422, ERROR_CODES.PRODUCT_IN_STOCK, 'Product is currently in stock — no notification needed.');
|
|
183
|
+
}
|
|
184
|
+
const existing = await em.findOne(AvailabilityNotification, {
|
|
185
|
+
productId: input.productId,
|
|
186
|
+
variantId: input.variantId ?? null,
|
|
187
|
+
email: input.email,
|
|
188
|
+
notifiedAt: null,
|
|
189
|
+
status: 'queued',
|
|
190
|
+
});
|
|
191
|
+
if (existing)
|
|
192
|
+
return existing;
|
|
193
|
+
// D-187 — resolved **before** the first managed entity is touched. `em.create`
|
|
194
|
+
// registers the row with the unit of work, so a refusal raised after it
|
|
195
|
+
// would leave an owned, unattributed subscription there for whatever
|
|
196
|
+
// flushes this request next.
|
|
197
|
+
const owner = await this.#resolveOwner(input.customerAccountId);
|
|
198
|
+
const subscription = em.create(AvailabilityNotification, {
|
|
199
|
+
productId: input.productId,
|
|
200
|
+
...(input.variantId !== undefined && input.variantId !== null
|
|
201
|
+
? { variantId: input.variantId }
|
|
202
|
+
: {}),
|
|
203
|
+
// D-187 — the only place a subscription is inserted, and the organisation
|
|
204
|
+
// goes in with the account. `ownerColumns` takes a *resolved* owner, so
|
|
205
|
+
// the customer branch has no shape in which the organisation could be
|
|
206
|
+
// omitted; the anonymous branch has none to carry (FR-011).
|
|
207
|
+
...ownerColumns(owner),
|
|
208
|
+
email: input.email,
|
|
209
|
+
status: 'queued',
|
|
210
|
+
});
|
|
211
|
+
await em.persistAndFlush(subscription);
|
|
212
|
+
return subscription;
|
|
213
|
+
}
|
|
214
|
+
async cancel(id) {
|
|
215
|
+
// command-coverage-ignore: customer notification opt-out — flips a
|
|
216
|
+
// self-service subscription to cancelled, not an audited domain mutation.
|
|
217
|
+
const em = this.emFactory();
|
|
218
|
+
const row = await em.findOne(AvailabilityNotification, { id });
|
|
219
|
+
if (!row) {
|
|
220
|
+
throw new HttpError(404, 'AVAILABILITY_NOTIFICATION_NOT_FOUND', 'Subscription not found.');
|
|
221
|
+
}
|
|
222
|
+
row.status = 'cancelled';
|
|
223
|
+
await em.flush();
|
|
224
|
+
}
|
|
225
|
+
async listForAdmin(filter = {}) {
|
|
226
|
+
const em = this.emFactory();
|
|
227
|
+
const page = Math.max(0, filter.page ?? 0);
|
|
228
|
+
const pageSize = Math.min(Math.max(1, filter.pageSize ?? 50), 200);
|
|
229
|
+
const where = {};
|
|
230
|
+
if (filter.productId)
|
|
231
|
+
where['productId'] = filter.productId;
|
|
232
|
+
if (filter.status)
|
|
233
|
+
where['status'] = filter.status;
|
|
234
|
+
const [rows, total] = await em.findAndCount(AvailabilityNotification, where, {
|
|
235
|
+
orderBy: { requestedAt: 'desc' },
|
|
236
|
+
offset: page * pageSize,
|
|
237
|
+
limit: pageSize,
|
|
238
|
+
});
|
|
239
|
+
if (rows.length === 0)
|
|
240
|
+
return { items: [], page, pageSize, total };
|
|
241
|
+
const productIds = Array.from(new Set(rows.map((r) => r.productId)));
|
|
242
|
+
const products = await this.catalogProducts.findByIds(productIds);
|
|
243
|
+
const productById = new Map(products.map((p) => [p.id, p]));
|
|
244
|
+
const customerIds = Array.from(new Set(rows.map((r) => r.customerAccountId).filter((id) => typeof id === 'string')));
|
|
245
|
+
const customers = customerIds.length
|
|
246
|
+
? await this.customerAccounts.findByIds(customerIds)
|
|
247
|
+
: [];
|
|
248
|
+
const customerById = new Map(customers.map((c) => [c.id, c]));
|
|
249
|
+
return {
|
|
250
|
+
items: rows.map((r) => {
|
|
251
|
+
const p = productById.get(r.productId);
|
|
252
|
+
const productName = p
|
|
253
|
+
? p.name['en-US'] ?? Object.values(p.name)[0] ?? p.sku
|
|
254
|
+
: '—';
|
|
255
|
+
const email = r.email ??
|
|
256
|
+
(r.customerAccountId ? customerById.get(r.customerAccountId)?.email ?? '' : '');
|
|
257
|
+
return {
|
|
258
|
+
id: r.id,
|
|
259
|
+
productId: r.productId,
|
|
260
|
+
productName: String(productName),
|
|
261
|
+
productSku: p?.sku ?? '',
|
|
262
|
+
customerAccountId: r.customerAccountId ?? null,
|
|
263
|
+
email,
|
|
264
|
+
status: r.status,
|
|
265
|
+
queuedAt: r.requestedAt.toISOString(),
|
|
266
|
+
notifiedAt: r.notifiedAt ? r.notifiedAt.toISOString() : null,
|
|
267
|
+
};
|
|
268
|
+
}),
|
|
269
|
+
page,
|
|
270
|
+
pageSize,
|
|
271
|
+
total,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Restock fan-out — invoked by AvailabilityWorker when cumulative on-hand
|
|
276
|
+
* crosses from 0 to > 0 for a (product, variant) pair. Emits one mail
|
|
277
|
+
* per queued row and flips status → 'notified'.
|
|
278
|
+
*/
|
|
279
|
+
async processRestockedFanOut(input) {
|
|
280
|
+
// command-coverage-ignore: background notification delivery — flips queued
|
|
281
|
+
// subscriptions to 'notified' after send, delivery bookkeeping (not domain).
|
|
282
|
+
if (!this.mailer && !this.templateEmail)
|
|
283
|
+
return { notified: 0 };
|
|
284
|
+
const em = this.emFactory();
|
|
285
|
+
const where = {
|
|
286
|
+
productId: input.productId,
|
|
287
|
+
status: 'queued',
|
|
288
|
+
notifiedAt: null,
|
|
289
|
+
};
|
|
290
|
+
if (input.variantId === undefined || input.variantId === null) {
|
|
291
|
+
where['variantId'] = null;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
where['variantId'] = input.variantId;
|
|
295
|
+
}
|
|
296
|
+
const rows = await em.find(AvailabilityNotification, where);
|
|
297
|
+
if (rows.length === 0)
|
|
298
|
+
return { notified: 0 };
|
|
299
|
+
const product = await this.catalogProducts.findById(input.productId);
|
|
300
|
+
const productName = product
|
|
301
|
+
? product.name['en-US'] ?? Object.values(product.name)[0] ?? product.sku
|
|
302
|
+
: 'product';
|
|
303
|
+
const customerIds = Array.from(new Set(rows.map((r) => r.customerAccountId).filter((id) => typeof id === 'string')));
|
|
304
|
+
const customers = customerIds.length
|
|
305
|
+
? await this.customerAccounts.findByIds(customerIds)
|
|
306
|
+
: [];
|
|
307
|
+
const emailById = new Map(customers.map((c) => [c.id, c.email]));
|
|
308
|
+
const now = new Date();
|
|
309
|
+
let notified = 0;
|
|
310
|
+
for (const row of rows) {
|
|
311
|
+
const to = row.email ?? (row.customerAccountId ? emailById.get(row.customerAccountId) : null);
|
|
312
|
+
if (!to) {
|
|
313
|
+
row.status = 'cancelled';
|
|
314
|
+
row.notifiedAt = now;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const messageId = `availability:${row.id}`;
|
|
318
|
+
const meta = {
|
|
319
|
+
productId: input.productId,
|
|
320
|
+
variantId: input.variantId ?? null,
|
|
321
|
+
notificationId: row.id,
|
|
322
|
+
};
|
|
323
|
+
let sentViaTemplate = false;
|
|
324
|
+
if (this.templateEmail) {
|
|
325
|
+
sentViaTemplate = await this.templateEmail.trySend({
|
|
326
|
+
code: 'availability_back_in_stock',
|
|
327
|
+
to,
|
|
328
|
+
messageId,
|
|
329
|
+
variables: { product: { name: productName } },
|
|
330
|
+
meta,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (!sentViaTemplate && this.mailer) {
|
|
334
|
+
const outcome = await this.mailer.send({
|
|
335
|
+
messageId,
|
|
336
|
+
to,
|
|
337
|
+
subject: `Back in stock: ${productName}`,
|
|
338
|
+
text: `Good news — "${productName}" is available again.`,
|
|
339
|
+
kind: 'availability_back_in_stock',
|
|
340
|
+
meta,
|
|
341
|
+
});
|
|
342
|
+
if (outcome.status !== 'sent') {
|
|
343
|
+
// Still marked notified below: the transport's one suppression is an
|
|
344
|
+
// already-accepted `messageId`, so this subscriber has the message.
|
|
345
|
+
console.warn('[inventory] the back-in-stock e-mail was not sent', {
|
|
346
|
+
notificationId: row.id,
|
|
347
|
+
reason: outcome.reason,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
row.status = 'notified';
|
|
352
|
+
row.notifiedAt = now;
|
|
353
|
+
notified += 1;
|
|
354
|
+
}
|
|
355
|
+
await em.flush();
|
|
356
|
+
return { notified };
|
|
357
|
+
}
|
|
358
|
+
/** Stand-in helper used to keep the unused entity import alive. */
|
|
359
|
+
static getStockLevelEntity() {
|
|
360
|
+
return StockLevel;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
//# sourceMappingURL=availability-notification-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"availability-notification-service.js","sourceRoot":"","sources":["../../../src/backend/services/availability-notification-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import type { CatalogProductReadPort, CustomerAccountReadPort, EmailMailerPort } from '@endora-commerce/contracts';
|
|
3
|
+
export interface AdjustedPayload {
|
|
4
|
+
productId: string;
|
|
5
|
+
warehouseId: string;
|
|
6
|
+
variantId: string | null;
|
|
7
|
+
before: number;
|
|
8
|
+
after: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Availability worker (T136 / FR-061).
|
|
12
|
+
*
|
|
13
|
+
* Call `dispatchForStockIncrease({ productId, variantId? })` after any
|
|
14
|
+
* stock-mutation path that raises a (product, variant) row's onHand. The
|
|
15
|
+
* worker:
|
|
16
|
+
*
|
|
17
|
+
* 1. Loads every AvailabilityNotification for that (productId, variantId)
|
|
18
|
+
* that hasn't yet been notified.
|
|
19
|
+
* 2. For each subscriber, sends a back-in-stock email via the injected
|
|
20
|
+
* Mailer.
|
|
21
|
+
* 3. Marks the notification consumed (`notifiedAt = now`) inside one
|
|
22
|
+
* flush so a worker crash mid-batch doesn't double-send (the next
|
|
23
|
+
* run re-loads only un-notified rows).
|
|
24
|
+
*
|
|
25
|
+
* Production composition can swap synchronous dispatch for a BullMQ job
|
|
26
|
+
* by binding a queue producer to the same method signature.
|
|
27
|
+
*/
|
|
28
|
+
export declare class AvailabilityWorker {
|
|
29
|
+
private readonly emFactory;
|
|
30
|
+
private readonly mailer;
|
|
31
|
+
/** `catalogProductReadPort`, owned by `catalog` (feature 075, Phase C). */
|
|
32
|
+
private readonly catalogProducts;
|
|
33
|
+
/** `customerAccountReadPort`, owned by `customer_accounts` (feature 075). */
|
|
34
|
+
private readonly customerAccounts;
|
|
35
|
+
constructor(emFactory: () => EntityManager, mailer: EmailMailerPort,
|
|
36
|
+
/** `catalogProductReadPort`, owned by `catalog` (feature 075, Phase C). */
|
|
37
|
+
catalogProducts: CatalogProductReadPort,
|
|
38
|
+
/** `customerAccountReadPort`, owned by `customer_accounts` (feature 075). */
|
|
39
|
+
customerAccounts: CustomerAccountReadPort);
|
|
40
|
+
dispatchForStockIncrease(input: {
|
|
41
|
+
productId: string;
|
|
42
|
+
variantId?: string | null;
|
|
43
|
+
}): Promise<{
|
|
44
|
+
notified: number;
|
|
45
|
+
}>;
|
|
46
|
+
/**
|
|
47
|
+
* `inventory.adjusted.v1` — fan out only when cumulative on-hand across all
|
|
48
|
+
* warehouses crossed from 0 to > 0 for the (product, variant) pair, not on
|
|
49
|
+
* every `stock_levels` row tweak.
|
|
50
|
+
*
|
|
51
|
+
* The subscription lives in this module's `backend.ts` and goes through
|
|
52
|
+
* `ctx.subscribe`, so a switched-off `inventory` sends no back-in-stock mail
|
|
53
|
+
* (issue #107). It used to be a bare `eventBus.on` here.
|
|
54
|
+
*/
|
|
55
|
+
handleAdjusted(payload: AdjustedPayload): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=availability-worker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"availability-worker.d.ts","sourceRoot":"","sources":["../../../src/backend/services/availability-worker.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EACV,sBAAsB,EACtB,uBAAuB,EACvB,eAAe,EAChB,MAAM,4BAA4B,CAAC;AAIpC,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,kBAAkB;IAE3B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,2EAA2E;IAC3E,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,6EAA6E;IAC7E,OAAO,CAAC,QAAQ,CAAC,gBAAgB;gBALhB,SAAS,EAAE,MAAM,aAAa,EAC9B,MAAM,EAAE,eAAe;IACxC,2EAA2E;IAC1D,eAAe,EAAE,sBAAsB;IACxD,6EAA6E;IAC5D,gBAAgB,EAAE,uBAAuB;IAGtD,wBAAwB,CAAC,KAAK,EAAE;QACpC,SAAS,EAAE,MAAM,CAAC;QAClB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;KAC3B,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IA2EjC;;;;;;;;OAQG;IACG,cAAc,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;CAwB9D"}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { AvailabilityNotification } from '../entities/availability-notification.entity.js';
|
|
2
|
+
import { withSystemScope } from '@endora-commerce/platform/tenancy';
|
|
3
|
+
/**
|
|
4
|
+
* Availability worker (T136 / FR-061).
|
|
5
|
+
*
|
|
6
|
+
* Call `dispatchForStockIncrease({ productId, variantId? })` after any
|
|
7
|
+
* stock-mutation path that raises a (product, variant) row's onHand. The
|
|
8
|
+
* worker:
|
|
9
|
+
*
|
|
10
|
+
* 1. Loads every AvailabilityNotification for that (productId, variantId)
|
|
11
|
+
* that hasn't yet been notified.
|
|
12
|
+
* 2. For each subscriber, sends a back-in-stock email via the injected
|
|
13
|
+
* Mailer.
|
|
14
|
+
* 3. Marks the notification consumed (`notifiedAt = now`) inside one
|
|
15
|
+
* flush so a worker crash mid-batch doesn't double-send (the next
|
|
16
|
+
* run re-loads only un-notified rows).
|
|
17
|
+
*
|
|
18
|
+
* Production composition can swap synchronous dispatch for a BullMQ job
|
|
19
|
+
* by binding a queue producer to the same method signature.
|
|
20
|
+
*/
|
|
21
|
+
export class AvailabilityWorker {
|
|
22
|
+
emFactory;
|
|
23
|
+
mailer;
|
|
24
|
+
catalogProducts;
|
|
25
|
+
customerAccounts;
|
|
26
|
+
constructor(emFactory, mailer,
|
|
27
|
+
/** `catalogProductReadPort`, owned by `catalog` (feature 075, Phase C). */
|
|
28
|
+
catalogProducts,
|
|
29
|
+
/** `customerAccountReadPort`, owned by `customer_accounts` (feature 075). */
|
|
30
|
+
customerAccounts) {
|
|
31
|
+
this.emFactory = emFactory;
|
|
32
|
+
this.mailer = mailer;
|
|
33
|
+
this.catalogProducts = catalogProducts;
|
|
34
|
+
this.customerAccounts = customerAccounts;
|
|
35
|
+
}
|
|
36
|
+
async dispatchForStockIncrease(input) {
|
|
37
|
+
// command-coverage-ignore: background restock fan-out — stamps notifiedAt on
|
|
38
|
+
// subscriptions as it sends, delivery bookkeeping (not an audited domain write).
|
|
39
|
+
// Feature 050 — triggered off a stock-increase event; may run detached, so
|
|
40
|
+
// scope the AvailabilityNotification reads under a system context.
|
|
41
|
+
return withSystemScope('availability stock-increase', async () => {
|
|
42
|
+
const em = this.emFactory();
|
|
43
|
+
const product = await this.catalogProducts.findById(input.productId);
|
|
44
|
+
if (!product)
|
|
45
|
+
return { notified: 0 };
|
|
46
|
+
const where = {
|
|
47
|
+
productId: input.productId,
|
|
48
|
+
notifiedAt: null,
|
|
49
|
+
};
|
|
50
|
+
if (input.variantId === undefined || input.variantId === null) {
|
|
51
|
+
where['variantId'] = null;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
where['variantId'] = input.variantId;
|
|
55
|
+
}
|
|
56
|
+
const subscriptions = await em.find(AvailabilityNotification, where);
|
|
57
|
+
if (subscriptions.length === 0)
|
|
58
|
+
return { notified: 0 };
|
|
59
|
+
const customerIds = Array.from(new Set(subscriptions
|
|
60
|
+
.map((s) => s.customerAccountId)
|
|
61
|
+
.filter((id) => typeof id === 'string')));
|
|
62
|
+
const customers = customerIds.length > 0 ? await this.customerAccounts.findByIds(customerIds) : [];
|
|
63
|
+
const emailById = new Map(customers.map((c) => [c.id, c.email]));
|
|
64
|
+
const productName = product.name['en-US'] ?? Object.values(product.name)[0] ?? product.sku;
|
|
65
|
+
const now = new Date();
|
|
66
|
+
for (const sub of subscriptions) {
|
|
67
|
+
// Feature 010: subscriptions can carry an `email` directly
|
|
68
|
+
// (anonymous path). Prefer that; otherwise fall back to the
|
|
69
|
+
// customer-account email.
|
|
70
|
+
const to = sub.email ?? (sub.customerAccountId ? emailById.get(sub.customerAccountId) : null);
|
|
71
|
+
if (!to) {
|
|
72
|
+
// Customer was deleted; mark consumed anyway so we stop retrying.
|
|
73
|
+
sub.notifiedAt = now;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const outcome = await this.mailer.send({
|
|
77
|
+
messageId: `availability:${sub.id}`,
|
|
78
|
+
to,
|
|
79
|
+
subject: `Back in stock: ${productName}`,
|
|
80
|
+
text: `Good news — "${productName}" is available again.`,
|
|
81
|
+
kind: 'availability_back_in_stock',
|
|
82
|
+
meta: {
|
|
83
|
+
productId: input.productId,
|
|
84
|
+
variantId: input.variantId ?? null,
|
|
85
|
+
notificationId: sub.id,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
if (outcome.status !== 'sent') {
|
|
89
|
+
// The subscription is still consumed: the one suppression a transport
|
|
90
|
+
// performs is an already-accepted `messageId`, which means this
|
|
91
|
+
// subscriber was notified by an earlier run. Re-sending it is the
|
|
92
|
+
// duplicate the idempotency key exists to prevent.
|
|
93
|
+
console.warn('[inventory] the back-in-stock e-mail was not sent', {
|
|
94
|
+
notificationId: sub.id,
|
|
95
|
+
reason: outcome.reason,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
sub.notifiedAt = now;
|
|
99
|
+
}
|
|
100
|
+
await em.flush();
|
|
101
|
+
return { notified: subscriptions.length };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* `inventory.adjusted.v1` — fan out only when cumulative on-hand across all
|
|
106
|
+
* warehouses crossed from 0 to > 0 for the (product, variant) pair, not on
|
|
107
|
+
* every `stock_levels` row tweak.
|
|
108
|
+
*
|
|
109
|
+
* The subscription lives in this module's `backend.ts` and goes through
|
|
110
|
+
* `ctx.subscribe`, so a switched-off `inventory` sends no back-in-stock mail
|
|
111
|
+
* (issue #107). It used to be a bare `eventBus.on` here.
|
|
112
|
+
*/
|
|
113
|
+
async handleAdjusted(payload) {
|
|
114
|
+
if (payload.after <= 0)
|
|
115
|
+
return;
|
|
116
|
+
const em = this.emFactory();
|
|
117
|
+
// `em.execute`, not `em.getKnex()`: a knex handle takes its own pooled
|
|
118
|
+
// connection, so a caller that raised this event from inside a transaction
|
|
119
|
+
// would have the sum answered from outside it (issue #207).
|
|
120
|
+
const variantClause = payload.variantId === null ? 'variant_id is null' : 'variant_id = ?';
|
|
121
|
+
const variantParams = payload.variantId === null ? [] : [payload.variantId];
|
|
122
|
+
const sumRows = (await em.execute(`select sum(on_hand) as on_hand
|
|
123
|
+
from stock_levels
|
|
124
|
+
where product_id = ? and ${variantClause}`, [payload.productId, ...variantParams]));
|
|
125
|
+
const cumulativeAfter = Number(sumRows[0]?.on_hand ?? 0);
|
|
126
|
+
const cumulativeBefore = cumulativeAfter - (payload.after - payload.before);
|
|
127
|
+
if (cumulativeBefore > 0 || cumulativeAfter <= 0)
|
|
128
|
+
return;
|
|
129
|
+
await this.dispatchForStockIncrease({
|
|
130
|
+
productId: payload.productId,
|
|
131
|
+
variantId: payload.variantId,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=availability-worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"availability-worker.js","sourceRoot":"","sources":["../../../src/backend/services/availability-worker.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
2
|
+
import type { CatalogProductReadPort, StockImportResult } from '@endora-commerce/contracts';
|
|
3
|
+
import type { EventBus } from '@endora-commerce/platform/events';
|
|
4
|
+
import type { AuditPort } from '@endora-commerce/platform/kernel';
|
|
5
|
+
import type { InventoryAuditContext } from '../plugin.js';
|
|
6
|
+
export interface CsvImportInput {
|
|
7
|
+
csv: string;
|
|
8
|
+
warehouseId: string;
|
|
9
|
+
dryRun?: boolean;
|
|
10
|
+
/** Optional original filename — surfaces in the audit row summary. */
|
|
11
|
+
fileName?: string | null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* CSV stock importer (US7 / FR-031).
|
|
15
|
+
*
|
|
16
|
+
* Accepts a CSV with a `sku,onHand` header row and applies the on-hand
|
|
17
|
+
* value for each product *into the chosen warehouse*. The format is:
|
|
18
|
+
*
|
|
19
|
+
* sku,onHand
|
|
20
|
+
* PRD-001,42
|
|
21
|
+
* PRD-002,0
|
|
22
|
+
*
|
|
23
|
+
* Rows whose SKU does not resolve to a Product produce a
|
|
24
|
+
* `product_not_found` error. Negative or non-integer onHand values
|
|
25
|
+
* produce `invalid_quantity`. Malformed rows (wrong column count, no
|
|
26
|
+
* comma) produce `malformed_row`. The function returns a summary for
|
|
27
|
+
* the operator UI; the file itself is never persisted.
|
|
28
|
+
*
|
|
29
|
+
* Implementation note (research §R8): Node-native streaming + manual
|
|
30
|
+
* row split keeps the dep graph minimal. The XLSX path lives in a
|
|
31
|
+
* sibling file and uses `read-excel-file` (selected per T003).
|
|
32
|
+
*/
|
|
33
|
+
export declare class CsvStockImporter {
|
|
34
|
+
private readonly emFactory;
|
|
35
|
+
/**
|
|
36
|
+
* `catalogProductReadPort`, owned by `catalog` (feature 075, Phase C). A
|
|
37
|
+
* sheet addresses a product by SKU, so somebody has to ask `catalog`; the
|
|
38
|
+
* `product_not_found` row error is raised from its answer.
|
|
39
|
+
*/
|
|
40
|
+
private readonly catalogProducts;
|
|
41
|
+
private readonly eventBus?;
|
|
42
|
+
private readonly auditLog?;
|
|
43
|
+
constructor(emFactory: () => EntityManager,
|
|
44
|
+
/**
|
|
45
|
+
* `catalogProductReadPort`, owned by `catalog` (feature 075, Phase C). A
|
|
46
|
+
* sheet addresses a product by SKU, so somebody has to ask `catalog`; the
|
|
47
|
+
* `product_not_found` row error is raised from its answer.
|
|
48
|
+
*/
|
|
49
|
+
catalogProducts: CatalogProductReadPort, eventBus?: EventBus | undefined, auditLog?: AuditPort | undefined);
|
|
50
|
+
run(input: CsvImportInput, auditCtx?: InventoryAuditContext): Promise<StockImportResult>;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=csv-stock-importer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"csv-stock-importer.d.ts","sourceRoot":"","sources":["../../../src/backend/services/csv-stock-importer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EACV,sBAAsB,EAEtB,iBAAiB,EAClB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAIjE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAClE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAE1D,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,sEAAsE;IACtE,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,gBAAgB;IAEzB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;IAC1B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;gBART,SAAS,EAAE,MAAM,aAAa;IAC/C;;;;OAIG;IACc,eAAe,EAAE,sBAAsB,EACvC,QAAQ,CAAC,EAAE,QAAQ,YAAA,EACnB,QAAQ,CAAC,EAAE,SAAS,YAAA;IAGjC,GAAG,CACP,KAAK,EAAE,cAAc,EACrB,QAAQ,CAAC,EAAE,qBAAqB,GAC/B,OAAO,CAAC,iBAAiB,CAAC;CAyH9B"}
|