@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.
Files changed (223) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/admin/api/warehouses-client.d.ts +22 -0
  4. package/dist/admin/api/warehouses-client.d.ts.map +1 -0
  5. package/dist/admin/api/warehouses-client.js +32 -0
  6. package/dist/admin/api/warehouses-client.js.map +1 -0
  7. package/dist/admin/index.d.ts +43 -0
  8. package/dist/admin/index.d.ts.map +1 -0
  9. package/dist/admin/index.js +190 -0
  10. package/dist/admin/index.js.map +1 -0
  11. package/dist/admin/pages/AvailabilityNotificationsPage.d.ts +15 -0
  12. package/dist/admin/pages/AvailabilityNotificationsPage.d.ts.map +1 -0
  13. package/dist/admin/pages/AvailabilityNotificationsPage.js +111 -0
  14. package/dist/admin/pages/AvailabilityNotificationsPage.js.map +1 -0
  15. package/dist/admin/pages/InventoryPage.d.ts +17 -0
  16. package/dist/admin/pages/InventoryPage.d.ts.map +1 -0
  17. package/dist/admin/pages/InventoryPage.js +135 -0
  18. package/dist/admin/pages/InventoryPage.js.map +1 -0
  19. package/dist/admin/pages/LowStockPage.d.ts +16 -0
  20. package/dist/admin/pages/LowStockPage.d.ts.map +1 -0
  21. package/dist/admin/pages/LowStockPage.js +61 -0
  22. package/dist/admin/pages/LowStockPage.js.map +1 -0
  23. package/dist/admin/pages/StockImportWizard.d.ts +17 -0
  24. package/dist/admin/pages/StockImportWizard.d.ts.map +1 -0
  25. package/dist/admin/pages/StockImportWizard.js +111 -0
  26. package/dist/admin/pages/StockImportWizard.js.map +1 -0
  27. package/dist/admin/pages/WarehouseEditor.d.ts +12 -0
  28. package/dist/admin/pages/WarehouseEditor.d.ts.map +1 -0
  29. package/dist/admin/pages/WarehouseEditor.js +194 -0
  30. package/dist/admin/pages/WarehouseEditor.js.map +1 -0
  31. package/dist/admin/pages/WarehousesList.d.ts +12 -0
  32. package/dist/admin/pages/WarehousesList.d.ts.map +1 -0
  33. package/dist/admin/pages/WarehousesList.js +69 -0
  34. package/dist/admin/pages/WarehousesList.js.map +1 -0
  35. package/dist/admin/zones/ChannelWarehouses.d.ts +49 -0
  36. package/dist/admin/zones/ChannelWarehouses.d.ts.map +1 -0
  37. package/dist/admin/zones/ChannelWarehouses.js +109 -0
  38. package/dist/admin/zones/ChannelWarehouses.js.map +1 -0
  39. package/dist/backend/demo/reset.d.ts +18 -0
  40. package/dist/backend/demo/reset.d.ts.map +1 -0
  41. package/dist/backend/demo/reset.js +20 -0
  42. package/dist/backend/demo/reset.js.map +1 -0
  43. package/dist/backend/demo/rows.d.ts +38 -0
  44. package/dist/backend/demo/rows.d.ts.map +1 -0
  45. package/dist/backend/demo/rows.js +38 -0
  46. package/dist/backend/demo/rows.js.map +1 -0
  47. package/dist/backend/demo/seed.d.ts +26 -0
  48. package/dist/backend/demo/seed.d.ts.map +1 -0
  49. package/dist/backend/demo/seed.js +65 -0
  50. package/dist/backend/demo/seed.js.map +1 -0
  51. package/dist/backend/email-templates/transactional-defaults.d.ts +11 -0
  52. package/dist/backend/email-templates/transactional-defaults.d.ts.map +1 -0
  53. package/dist/backend/email-templates/transactional-defaults.js +49 -0
  54. package/dist/backend/email-templates/transactional-defaults.js.map +1 -0
  55. package/dist/backend/entities/availability-notification.entity.d.ts +49 -0
  56. package/dist/backend/entities/availability-notification.entity.d.ts.map +1 -0
  57. package/dist/backend/entities/availability-notification.entity.js +93 -0
  58. package/dist/backend/entities/availability-notification.entity.js.map +1 -0
  59. package/dist/backend/entities/inventory-threshold.entity.d.ts +24 -0
  60. package/dist/backend/entities/inventory-threshold.entity.d.ts.map +1 -0
  61. package/dist/backend/entities/inventory-threshold.entity.js +61 -0
  62. package/dist/backend/entities/inventory-threshold.entity.js.map +1 -0
  63. package/dist/backend/entities/product-warehouse-low-stock-threshold.entity.d.ts +15 -0
  64. package/dist/backend/entities/product-warehouse-low-stock-threshold.entity.d.ts.map +1 -0
  65. package/dist/backend/entities/product-warehouse-low-stock-threshold.entity.js +51 -0
  66. package/dist/backend/entities/product-warehouse-low-stock-threshold.entity.js.map +1 -0
  67. package/dist/backend/entities/stock-allocation.entity.d.ts +22 -0
  68. package/dist/backend/entities/stock-allocation.entity.d.ts.map +1 -0
  69. package/dist/backend/entities/stock-allocation.entity.js +69 -0
  70. package/dist/backend/entities/stock-allocation.entity.js.map +1 -0
  71. package/dist/backend/entities/stock-level.entity.d.ts +24 -0
  72. package/dist/backend/entities/stock-level.entity.d.ts.map +1 -0
  73. package/dist/backend/entities/stock-level.entity.js +74 -0
  74. package/dist/backend/entities/stock-level.entity.js.map +1 -0
  75. package/dist/backend/entities/warehouse-channel-assignment.entity.d.ts +17 -0
  76. package/dist/backend/entities/warehouse-channel-assignment.entity.d.ts.map +1 -0
  77. package/dist/backend/entities/warehouse-channel-assignment.entity.js +60 -0
  78. package/dist/backend/entities/warehouse-channel-assignment.entity.js.map +1 -0
  79. package/dist/backend/entities/warehouse.entity.d.ts +37 -0
  80. package/dist/backend/entities/warehouse.entity.d.ts.map +1 -0
  81. package/dist/backend/entities/warehouse.entity.js +90 -0
  82. package/dist/backend/entities/warehouse.entity.js.map +1 -0
  83. package/dist/backend/index.d.ts +139 -0
  84. package/dist/backend/index.d.ts.map +1 -0
  85. package/dist/backend/index.js +351 -0
  86. package/dist/backend/index.js.map +1 -0
  87. package/dist/backend/plugin.d.ts +113 -0
  88. package/dist/backend/plugin.d.ts.map +1 -0
  89. package/dist/backend/plugin.js +75 -0
  90. package/dist/backend/plugin.js.map +1 -0
  91. package/dist/backend/prompt-tools.d.ts +23 -0
  92. package/dist/backend/prompt-tools.d.ts.map +1 -0
  93. package/dist/backend/prompt-tools.js +77 -0
  94. package/dist/backend/prompt-tools.js.map +1 -0
  95. package/dist/backend/routes.admin.d.ts +62 -0
  96. package/dist/backend/routes.admin.d.ts.map +1 -0
  97. package/dist/backend/routes.admin.js +383 -0
  98. package/dist/backend/routes.admin.js.map +1 -0
  99. package/dist/backend/routes.d.ts +40 -0
  100. package/dist/backend/routes.d.ts.map +1 -0
  101. package/dist/backend/routes.js +263 -0
  102. package/dist/backend/routes.js.map +1 -0
  103. package/dist/backend/services/audit-references.d.ts +15 -0
  104. package/dist/backend/services/audit-references.d.ts.map +1 -0
  105. package/dist/backend/services/audit-references.js +27 -0
  106. package/dist/backend/services/audit-references.js.map +1 -0
  107. package/dist/backend/services/availability-notification-service.d.ts +155 -0
  108. package/dist/backend/services/availability-notification-service.d.ts.map +1 -0
  109. package/dist/backend/services/availability-notification-service.js +363 -0
  110. package/dist/backend/services/availability-notification-service.js.map +1 -0
  111. package/dist/backend/services/availability-worker.d.ts +57 -0
  112. package/dist/backend/services/availability-worker.d.ts.map +1 -0
  113. package/dist/backend/services/availability-worker.js +135 -0
  114. package/dist/backend/services/availability-worker.js.map +1 -0
  115. package/dist/backend/services/csv-stock-importer.d.ts +52 -0
  116. package/dist/backend/services/csv-stock-importer.d.ts.map +1 -0
  117. package/dist/backend/services/csv-stock-importer.js +154 -0
  118. package/dist/backend/services/csv-stock-importer.js.map +1 -0
  119. package/dist/backend/services/display-band-resolver.d.ts +19 -0
  120. package/dist/backend/services/display-band-resolver.d.ts.map +1 -0
  121. package/dist/backend/services/display-band-resolver.js +21 -0
  122. package/dist/backend/services/display-band-resolver.js.map +1 -0
  123. package/dist/backend/services/effective-fulfilment-strategy.d.ts +16 -0
  124. package/dist/backend/services/effective-fulfilment-strategy.d.ts.map +1 -0
  125. package/dist/backend/services/effective-fulfilment-strategy.js +16 -0
  126. package/dist/backend/services/effective-fulfilment-strategy.js.map +1 -0
  127. package/dist/backend/services/fulfilment-strategy-resolver.d.ts +13 -0
  128. package/dist/backend/services/fulfilment-strategy-resolver.d.ts.map +1 -0
  129. package/dist/backend/services/fulfilment-strategy-resolver.js +13 -0
  130. package/dist/backend/services/fulfilment-strategy-resolver.js.map +1 -0
  131. package/dist/backend/services/inventory-read-port.d.ts +65 -0
  132. package/dist/backend/services/inventory-read-port.d.ts.map +1 -0
  133. package/dist/backend/services/inventory-read-port.js +168 -0
  134. package/dist/backend/services/inventory-read-port.js.map +1 -0
  135. package/dist/backend/services/inventory-reservation-apply-port.d.ts +62 -0
  136. package/dist/backend/services/inventory-reservation-apply-port.d.ts.map +1 -0
  137. package/dist/backend/services/inventory-reservation-apply-port.js +134 -0
  138. package/dist/backend/services/inventory-reservation-apply-port.js.map +1 -0
  139. package/dist/backend/services/low-stock-alert-service.d.ts +87 -0
  140. package/dist/backend/services/low-stock-alert-service.d.ts.map +1 -0
  141. package/dist/backend/services/low-stock-alert-service.js +164 -0
  142. package/dist/backend/services/low-stock-alert-service.js.map +1 -0
  143. package/dist/backend/services/product-threshold-write.service.d.ts +38 -0
  144. package/dist/backend/services/product-threshold-write.service.d.ts.map +1 -0
  145. package/dist/backend/services/product-threshold-write.service.js +80 -0
  146. package/dist/backend/services/product-threshold-write.service.js.map +1 -0
  147. package/dist/backend/services/stock-import.service.d.ts +43 -0
  148. package/dist/backend/services/stock-import.service.d.ts.map +1 -0
  149. package/dist/backend/services/stock-import.service.js +124 -0
  150. package/dist/backend/services/stock-import.service.js.map +1 -0
  151. package/dist/backend/services/stock-level-service.d.ts +146 -0
  152. package/dist/backend/services/stock-level-service.d.ts.map +1 -0
  153. package/dist/backend/services/stock-level-service.js +489 -0
  154. package/dist/backend/services/stock-level-service.js.map +1 -0
  155. package/dist/backend/services/threshold-admin-service.d.ts +73 -0
  156. package/dist/backend/services/threshold-admin-service.d.ts.map +1 -0
  157. package/dist/backend/services/threshold-admin-service.js +167 -0
  158. package/dist/backend/services/threshold-admin-service.js.map +1 -0
  159. package/dist/backend/services/threshold-resolver.d.ts +23 -0
  160. package/dist/backend/services/threshold-resolver.d.ts.map +1 -0
  161. package/dist/backend/services/threshold-resolver.js +29 -0
  162. package/dist/backend/services/threshold-resolver.js.map +1 -0
  163. package/dist/backend/services/threshold-settings-mirror.d.ts +55 -0
  164. package/dist/backend/services/threshold-settings-mirror.d.ts.map +1 -0
  165. package/dist/backend/services/threshold-settings-mirror.js +84 -0
  166. package/dist/backend/services/threshold-settings-mirror.js.map +1 -0
  167. package/dist/backend/services/warehouse-channel-reconciler.d.ts +19 -0
  168. package/dist/backend/services/warehouse-channel-reconciler.d.ts.map +1 -0
  169. package/dist/backend/services/warehouse-channel-reconciler.js +65 -0
  170. package/dist/backend/services/warehouse-channel-reconciler.js.map +1 -0
  171. package/dist/backend/services/warehouse-channel-service.d.ts +57 -0
  172. package/dist/backend/services/warehouse-channel-service.d.ts.map +1 -0
  173. package/dist/backend/services/warehouse-channel-service.js +205 -0
  174. package/dist/backend/services/warehouse-channel-service.js.map +1 -0
  175. package/dist/backend/services/warehouse-country-reference.d.ts +14 -0
  176. package/dist/backend/services/warehouse-country-reference.d.ts.map +1 -0
  177. package/dist/backend/services/warehouse-country-reference.js +27 -0
  178. package/dist/backend/services/warehouse-country-reference.js.map +1 -0
  179. package/dist/backend/services/warehouse-service.d.ts +86 -0
  180. package/dist/backend/services/warehouse-service.d.ts.map +1 -0
  181. package/dist/backend/services/warehouse-service.js +267 -0
  182. package/dist/backend/services/warehouse-service.js.map +1 -0
  183. package/dist/manifest.d.ts +219 -0
  184. package/dist/manifest.d.ts.map +1 -0
  185. package/dist/manifest.js +377 -0
  186. package/dist/manifest.js.map +1 -0
  187. package/dist/migrations/20260503T182812_inventory_workflow.d.ts +39 -0
  188. package/dist/migrations/20260503T182812_inventory_workflow.d.ts.map +1 -0
  189. package/dist/migrations/20260503T182812_inventory_workflow.js +205 -0
  190. package/dist/migrations/20260503T182812_inventory_workflow.js.map +1 -0
  191. package/dist/migrations/20260611T140347_inventory_warehouse_default_low_stock_threshold.d.ts +14 -0
  192. package/dist/migrations/20260611T140347_inventory_warehouse_default_low_stock_threshold.d.ts.map +1 -0
  193. package/dist/migrations/20260611T140347_inventory_warehouse_default_low_stock_threshold.js +18 -0
  194. package/dist/migrations/20260611T140347_inventory_warehouse_default_low_stock_threshold.js.map +1 -0
  195. package/dist/migrations/20260611T140348_inventory_per_warehouse_low_stock_thresholds.d.ts +28 -0
  196. package/dist/migrations/20260611T140348_inventory_per_warehouse_low_stock_thresholds.d.ts.map +1 -0
  197. package/dist/migrations/20260611T140348_inventory_per_warehouse_low_stock_thresholds.js +48 -0
  198. package/dist/migrations/20260611T140348_inventory_per_warehouse_low_stock_thresholds.js.map +1 -0
  199. package/dist/migrations/20260818T081243_inventory_stock_allocation_order_item_fk.d.ts +44 -0
  200. package/dist/migrations/20260818T081243_inventory_stock_allocation_order_item_fk.d.ts.map +1 -0
  201. package/dist/migrations/20260818T081243_inventory_stock_allocation_order_item_fk.js +81 -0
  202. package/dist/migrations/20260818T081243_inventory_stock_allocation_order_item_fk.js.map +1 -0
  203. package/dist/migrations/20260830T182139_inventory_organization_attribution.d.ts +109 -0
  204. package/dist/migrations/20260830T182139_inventory_organization_attribution.d.ts.map +1 -0
  205. package/dist/migrations/20260830T182139_inventory_organization_attribution.js +178 -0
  206. package/dist/migrations/20260830T182139_inventory_organization_attribution.js.map +1 -0
  207. package/dist/migrations/20260912T125716_inventory_organization_warehouses.d.ts +34 -0
  208. package/dist/migrations/20260912T125716_inventory_organization_warehouses.d.ts.map +1 -0
  209. package/dist/migrations/20260912T125716_inventory_organization_warehouses.js +50 -0
  210. package/dist/migrations/20260912T125716_inventory_organization_warehouses.js.map +1 -0
  211. package/dist/migrations/index.d.ts +32 -0
  212. package/dist/migrations/index.d.ts.map +1 -0
  213. package/dist/migrations/index.js +39 -0
  214. package/dist/migrations/index.js.map +1 -0
  215. package/dist/ports/index.d.ts +183 -0
  216. package/dist/ports/index.d.ts.map +1 -0
  217. package/dist/ports/index.js +2 -0
  218. package/dist/ports/index.js.map +1 -0
  219. package/docs/inventory.md +177 -0
  220. package/i18n/en.json +32 -0
  221. package/i18n/pl.json +32 -0
  222. package/package.json +105 -0
  223. 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"}