@endora-commerce/mod-quote-requests 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 (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +59 -0
  3. package/dist/admin/index.d.ts +26 -0
  4. package/dist/admin/index.d.ts.map +1 -0
  5. package/dist/admin/index.js +56 -0
  6. package/dist/admin/index.js.map +1 -0
  7. package/dist/admin/pages/RfqCreatePage.d.ts +9 -0
  8. package/dist/admin/pages/RfqCreatePage.d.ts.map +1 -0
  9. package/dist/admin/pages/RfqCreatePage.js +163 -0
  10. package/dist/admin/pages/RfqCreatePage.js.map +1 -0
  11. package/dist/admin/pages/RfqDetail.d.ts +9 -0
  12. package/dist/admin/pages/RfqDetail.d.ts.map +1 -0
  13. package/dist/admin/pages/RfqDetail.js +399 -0
  14. package/dist/admin/pages/RfqDetail.js.map +1 -0
  15. package/dist/admin/pages/RfqList.d.ts +9 -0
  16. package/dist/admin/pages/RfqList.d.ts.map +1 -0
  17. package/dist/admin/pages/RfqList.js +214 -0
  18. package/dist/admin/pages/RfqList.js.map +1 -0
  19. package/dist/admin/validity.d.ts +52 -0
  20. package/dist/admin/validity.d.ts.map +1 -0
  21. package/dist/admin/validity.js +61 -0
  22. package/dist/admin/validity.js.map +1 -0
  23. package/dist/backend/entities/quote-request-event.entity.d.ts +24 -0
  24. package/dist/backend/entities/quote-request-event.entity.d.ts.map +1 -0
  25. package/dist/backend/entities/quote-request-event.entity.js +67 -0
  26. package/dist/backend/entities/quote-request-event.entity.js.map +1 -0
  27. package/dist/backend/entities/quote-request-item.entity.d.ts +34 -0
  28. package/dist/backend/entities/quote-request-item.entity.d.ts.map +1 -0
  29. package/dist/backend/entities/quote-request-item.entity.js +119 -0
  30. package/dist/backend/entities/quote-request-item.entity.js.map +1 -0
  31. package/dist/backend/entities/quote-request-notification-event.entity.d.ts +23 -0
  32. package/dist/backend/entities/quote-request-notification-event.entity.d.ts.map +1 -0
  33. package/dist/backend/entities/quote-request-notification-event.entity.js +72 -0
  34. package/dist/backend/entities/quote-request-notification-event.entity.js.map +1 -0
  35. package/dist/backend/entities/quote-request-revision.entity.d.ts +35 -0
  36. package/dist/backend/entities/quote-request-revision.entity.d.ts.map +1 -0
  37. package/dist/backend/entities/quote-request-revision.entity.js +67 -0
  38. package/dist/backend/entities/quote-request-revision.entity.js.map +1 -0
  39. package/dist/backend/entities/quote-request.entity.d.ts +90 -0
  40. package/dist/backend/entities/quote-request.entity.d.ts.map +1 -0
  41. package/dist/backend/entities/quote-request.entity.js +191 -0
  42. package/dist/backend/entities/quote-request.entity.js.map +1 -0
  43. package/dist/backend/index.d.ts +120 -0
  44. package/dist/backend/index.d.ts.map +1 -0
  45. package/dist/backend/index.js +361 -0
  46. package/dist/backend/index.js.map +1 -0
  47. package/dist/backend/plugin.d.ts +95 -0
  48. package/dist/backend/plugin.d.ts.map +1 -0
  49. package/dist/backend/plugin.js +152 -0
  50. package/dist/backend/plugin.js.map +1 -0
  51. package/dist/backend/routes.admin.d.ts +13 -0
  52. package/dist/backend/routes.admin.d.ts.map +1 -0
  53. package/dist/backend/routes.admin.js +82 -0
  54. package/dist/backend/routes.admin.js.map +1 -0
  55. package/dist/backend/routes.customer.d.ts +18 -0
  56. package/dist/backend/routes.customer.d.ts.map +1 -0
  57. package/dist/backend/routes.customer.js +98 -0
  58. package/dist/backend/routes.customer.js.map +1 -0
  59. package/dist/backend/routes.sales-reps.d.ts +12 -0
  60. package/dist/backend/routes.sales-reps.d.ts.map +1 -0
  61. package/dist/backend/routes.sales-reps.js +57 -0
  62. package/dist/backend/routes.sales-reps.js.map +1 -0
  63. package/dist/backend/services/order-completion-reactor.d.ts +32 -0
  64. package/dist/backend/services/order-completion-reactor.d.ts.map +1 -0
  65. package/dist/backend/services/order-completion-reactor.js +57 -0
  66. package/dist/backend/services/order-completion-reactor.js.map +1 -0
  67. package/dist/backend/services/quote-request-business-id-generator.d.ts +46 -0
  68. package/dist/backend/services/quote-request-business-id-generator.d.ts.map +1 -0
  69. package/dist/backend/services/quote-request-business-id-generator.js +69 -0
  70. package/dist/backend/services/quote-request-business-id-generator.js.map +1 -0
  71. package/dist/backend/services/quote-request-read-port.d.ts +36 -0
  72. package/dist/backend/services/quote-request-read-port.d.ts.map +1 -0
  73. package/dist/backend/services/quote-request-read-port.js +96 -0
  74. package/dist/backend/services/quote-request-read-port.js.map +1 -0
  75. package/dist/backend/services/raised-on-channel.d.ts +23 -0
  76. package/dist/backend/services/raised-on-channel.d.ts.map +1 -0
  77. package/dist/backend/services/raised-on-channel.js +26 -0
  78. package/dist/backend/services/raised-on-channel.js.map +1 -0
  79. package/dist/backend/services/rfq-admin-service.d.ts +75 -0
  80. package/dist/backend/services/rfq-admin-service.d.ts.map +1 -0
  81. package/dist/backend/services/rfq-admin-service.js +487 -0
  82. package/dist/backend/services/rfq-admin-service.js.map +1 -0
  83. package/dist/backend/services/rfq-event-service.d.ts +32 -0
  84. package/dist/backend/services/rfq-event-service.d.ts.map +1 -0
  85. package/dist/backend/services/rfq-event-service.js +28 -0
  86. package/dist/backend/services/rfq-event-service.js.map +1 -0
  87. package/dist/backend/services/rfq-expiry-worker.d.ts +50 -0
  88. package/dist/backend/services/rfq-expiry-worker.d.ts.map +1 -0
  89. package/dist/backend/services/rfq-expiry-worker.js +70 -0
  90. package/dist/backend/services/rfq-expiry-worker.js.map +1 -0
  91. package/dist/backend/services/rfq-notification-service.d.ts +36 -0
  92. package/dist/backend/services/rfq-notification-service.d.ts.map +1 -0
  93. package/dist/backend/services/rfq-notification-service.js +52 -0
  94. package/dist/backend/services/rfq-notification-service.js.map +1 -0
  95. package/dist/backend/services/rfq-revision-service.d.ts +70 -0
  96. package/dist/backend/services/rfq-revision-service.d.ts.map +1 -0
  97. package/dist/backend/services/rfq-revision-service.js +120 -0
  98. package/dist/backend/services/rfq-revision-service.js.map +1 -0
  99. package/dist/backend/services/rfq-service.d.ts +140 -0
  100. package/dist/backend/services/rfq-service.d.ts.map +1 -0
  101. package/dist/backend/services/rfq-service.js +786 -0
  102. package/dist/backend/services/rfq-service.js.map +1 -0
  103. package/dist/backend/services/sales-channel-attributions.d.ts +38 -0
  104. package/dist/backend/services/sales-channel-attributions.d.ts.map +1 -0
  105. package/dist/backend/services/sales-channel-attributions.js +48 -0
  106. package/dist/backend/services/sales-channel-attributions.js.map +1 -0
  107. package/dist/manifest.d.ts +203 -0
  108. package/dist/manifest.d.ts.map +1 -0
  109. package/dist/manifest.js +277 -0
  110. package/dist/manifest.js.map +1 -0
  111. package/dist/migrations/20260424T190112_quote_requests_init.d.ts +19 -0
  112. package/dist/migrations/20260424T190112_quote_requests_init.d.ts.map +1 -0
  113. package/dist/migrations/20260424T190112_quote_requests_init.js +65 -0
  114. package/dist/migrations/20260424T190112_quote_requests_init.js.map +1 -0
  115. package/dist/migrations/20260503T141344_quote_requests_workflow.d.ts +37 -0
  116. package/dist/migrations/20260503T141344_quote_requests_workflow.d.ts.map +1 -0
  117. package/dist/migrations/20260503T141344_quote_requests_workflow.js +251 -0
  118. package/dist/migrations/20260503T141344_quote_requests_workflow.js.map +1 -0
  119. package/dist/migrations/20260611T140415_quote_requests_qr_item_packaging.d.ts +10 -0
  120. package/dist/migrations/20260611T140415_quote_requests_qr_item_packaging.d.ts.map +1 -0
  121. package/dist/migrations/20260611T140415_quote_requests_qr_item_packaging.js +16 -0
  122. package/dist/migrations/20260611T140415_quote_requests_qr_item_packaging.js.map +1 -0
  123. package/dist/migrations/20260611T140417_quote_requests_business_id.d.ts +24 -0
  124. package/dist/migrations/20260611T140417_quote_requests_business_id.d.ts.map +1 -0
  125. package/dist/migrations/20260611T140417_quote_requests_business_id.js +34 -0
  126. package/dist/migrations/20260611T140417_quote_requests_business_id.js.map +1 -0
  127. package/dist/migrations/20260617T095510_quote_requests_backfill_admin_created_awaiting.d.ts +21 -0
  128. package/dist/migrations/20260617T095510_quote_requests_backfill_admin_created_awaiting.d.ts.map +1 -0
  129. package/dist/migrations/20260617T095510_quote_requests_backfill_admin_created_awaiting.js +29 -0
  130. package/dist/migrations/20260617T095510_quote_requests_backfill_admin_created_awaiting.js.map +1 -0
  131. package/dist/migrations/20260718T200342_quote_requests_quote_request_custom_field_values.d.ts +12 -0
  132. package/dist/migrations/20260718T200342_quote_requests_quote_request_custom_field_values.d.ts.map +1 -0
  133. package/dist/migrations/20260718T200342_quote_requests_quote_request_custom_field_values.js +16 -0
  134. package/dist/migrations/20260718T200342_quote_requests_quote_request_custom_field_values.js.map +1 -0
  135. package/dist/migrations/20260912T125614_quote_requests_quote_request_channel_attribution.d.ts +34 -0
  136. package/dist/migrations/20260912T125614_quote_requests_quote_request_channel_attribution.d.ts.map +1 -0
  137. package/dist/migrations/20260912T125614_quote_requests_quote_request_channel_attribution.js +57 -0
  138. package/dist/migrations/20260912T125614_quote_requests_quote_request_channel_attribution.js.map +1 -0
  139. package/dist/migrations/index.d.ts +53 -0
  140. package/dist/migrations/index.d.ts.map +1 -0
  141. package/dist/migrations/index.js +61 -0
  142. package/dist/migrations/index.js.map +1 -0
  143. package/dist/migrations/status-mapping.d.ts +18 -0
  144. package/dist/migrations/status-mapping.d.ts.map +1 -0
  145. package/dist/migrations/status-mapping.js +28 -0
  146. package/dist/migrations/status-mapping.js.map +1 -0
  147. package/docs/quote_requests.md +168 -0
  148. package/i18n/en.json +14 -0
  149. package/i18n/pl.json +14 -0
  150. package/package.json +99 -0
  151. package/tailwind.css +14 -0
@@ -0,0 +1,786 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { ERROR_CODES, isProductVisibleTo, } from '@endora-commerce/contracts';
3
+ import { HttpError } from '@endora-commerce/platform/http';
4
+ import { recordAuditFromContext } from '@endora-commerce/platform/commands';
5
+ import { QuoteRequest } from '../entities/quote-request.entity.js';
6
+ import { QuoteRequestItem } from '../entities/quote-request-item.entity.js';
7
+ import { QuoteRequestEvent, } from '../entities/quote-request-event.entity.js';
8
+ import {} from './rfq-notification-service.js';
9
+ import { productIdsInRequestChannel } from '@endora-commerce/platform/kernel';
10
+ import { raisedOnChannelId } from './raised-on-channel.js';
11
+ export class RfqService {
12
+ deps;
13
+ constructor(deps) {
14
+ this.deps = deps;
15
+ }
16
+ /**
17
+ * The products a quote line may name, keyed by id — the two scoping axes a
18
+ * buyer-facing product read owes, applied together.
19
+ *
20
+ * `isProductVisibleTo` is issue #227's: may this buyer see the row at all.
21
+ * The channel narrowing is issue #259's, and the predicate says in writing
22
+ * that it is *not* that answer — the channel belongs to the request, so it is
23
+ * read off the request scope (the same slot `raisedOnChannelId` stamps on the
24
+ * quote) and never re-resolved here. `null` from the narrowing means there is
25
+ * no request at all — a worker, a CLI script, a fixture calling this service
26
+ * directly — and then there is no channel for a line to be outside of.
27
+ *
28
+ * The admin's own create path is `RfqAdminService.createOnBehalf` and does
29
+ * not come through here. It applies neither axis, deliberately and as issue
30
+ * #227 left it: the operator is naming both the organisation and the lines,
31
+ * and the `quote_requests:write` permission is the enforcement.
32
+ *
33
+ * Both callers compare `size` against the requested set and answer one 404
34
+ * for any shortfall, so a line that is restricted, a line sold on another
35
+ * channel and a line naming a product that never existed are one response.
36
+ */
37
+ async #acquirableProductsById(productIds, ctx) {
38
+ const visible = (await this.deps.catalogProducts.findByIds(productIds)).filter((p) => isProductVisibleTo(p, rfqAudience(ctx)));
39
+ const publishedHere = await productIdsInRequestChannel(this.deps.channelMembership, visible.map((p) => p.id));
40
+ const acquirable = publishedHere ? visible.filter((p) => publishedHere.has(p.id)) : visible;
41
+ return new Map(acquirable.map((p) => [p.id, p]));
42
+ }
43
+ /** Feature 054 — co-transactional RFQ audit on `em` (actor from context). */
44
+ #audit(em, action, objectId, stateBefore, stateAfter) {
45
+ if (this.deps.auditLog) {
46
+ recordAuditFromContext(this.deps.auditLog, em, {
47
+ action,
48
+ objectType: 'quote_request',
49
+ objectId,
50
+ stateBefore,
51
+ stateAfter,
52
+ });
53
+ }
54
+ }
55
+ /**
56
+ * Draws the next business Quote Request ID, or `undefined` when no generator
57
+ * is wired (so the entity placeholder default applies). Shared with
58
+ * RfqAdminService so admin-created RFQs are numbered identically.
59
+ */
60
+ async generateBusinessId(em) {
61
+ return this.deps.businessId ? this.deps.businessId.generate(em) : undefined;
62
+ }
63
+ /**
64
+ * Resolves the VAT rate applied to a quote's net prices for an Organization.
65
+ *
66
+ * No unwired branch (issue #124): the resolver is required, and an absent
67
+ * `taxes` module surfaces through it as `MODULE_DISABLED` rather than as a
68
+ * quote silently priced at 0% VAT.
69
+ */
70
+ async taxRateForOrganization(organizationId) {
71
+ return this.deps.resolveTaxRate(organizationId);
72
+ }
73
+ // -------------------------------------------------------------------------
74
+ // Read paths
75
+ // -------------------------------------------------------------------------
76
+ async listForCustomer(ctx) {
77
+ const em = this.deps.emFactory();
78
+ // Feature 056 (T032) — an Organization Admin sees "their org(s)"; the org
79
+ // predicate is enforced by the always-on tenant filter (single-org, or the
80
+ // SUBTREE for a roll-up-enabled head-office login), so we do not pin
81
+ // `ctx.organizationId` here. A regular user still sees only their own RFQs.
82
+ const where = ctx.isOrgAdmin ? {} : { customerAccountId: ctx.customerAccountId };
83
+ const rfqs = await em.find(QuoteRequest, where, { orderBy: { createdAt: 'desc' } });
84
+ if (rfqs.length === 0)
85
+ return [];
86
+ const items = await em.find(QuoteRequestItem, {
87
+ quoteRequestId: { $in: rfqs.map((r) => r.id) },
88
+ });
89
+ const itemsByRfq = groupBy(items, (i) => i.quoteRequestId);
90
+ const requesterIds = [...new Set(rfqs.map((r) => r.customerAccountId))];
91
+ const requesters = await this.deps.customerAccounts.findByIds(requesterIds);
92
+ const requesterById = new Map(requesters.map((r) => [r.id, r]));
93
+ // Every RFQ in a customer listing belongs to the same Organization
94
+ // (the caller's), so the VAT rate is resolved once for the page.
95
+ const taxRate = await this.taxRateForOrganization(ctx.organizationId);
96
+ return rfqs.map((rfq) => {
97
+ const rfqItems = itemsByRfq.get(rfq.id) ?? [];
98
+ const requester = requesterById.get(rfq.customerAccountId);
99
+ return summarize(rfq, rfqItems, {
100
+ requesterDisplayName: requester ? customerDisplayName(requester) : null,
101
+ taxRate,
102
+ });
103
+ });
104
+ }
105
+ async getForCustomer(rfqId, ctx) {
106
+ // command-coverage-ignore: read-tracking side effect — advances
107
+ // last_customer_seen_revision_number on view, not an audited domain mutation.
108
+ const em = this.deps.emFactory();
109
+ const rfq = await this.findVisibleForCustomer(em, rfqId, ctx);
110
+ // Compute the response BEFORE bumping last_customer_seen_revision_number.
111
+ // The comparison block must diff against the revision the customer last
112
+ // saw, which is the value as-of-this-read; bumping first would always
113
+ // produce an empty diff.
114
+ const dto = await this.serializeFull(em, rfq, /* includeFullActorIdentity */ false);
115
+ // Side-effect on read: advance last_customer_seen so the next visit
116
+ // reflects what the customer has now seen (FR-035 + US3).
117
+ if (rfq.lastCustomerSeenRevisionNumber < rfq.currentRevisionNumber) {
118
+ rfq.lastCustomerSeenRevisionNumber = rfq.currentRevisionNumber;
119
+ await em.flush();
120
+ }
121
+ return dto;
122
+ }
123
+ // -------------------------------------------------------------------------
124
+ // Create + submit (US1)
125
+ // -------------------------------------------------------------------------
126
+ async createForCustomer(ctx, input) {
127
+ if (input.items.length === 0) {
128
+ throw new HttpError(400, ERROR_CODES.RFQ_EMPTY, 'Quote Request must have at least one line item.');
129
+ }
130
+ const em = this.deps.emFactory();
131
+ const productById = await this.#acquirableProductsById(input.items.map((it) => it.productId), ctx);
132
+ if (productById.size !== new Set(input.items.map((it) => it.productId)).size) {
133
+ // Issues #227 and #259 — a line the buyer may not see, and a line the
134
+ // request's channel does not publish, are both refused the same way a
135
+ // line naming a product that does not exist is. One message for all
136
+ // three, so the response cannot be used to tell them apart.
137
+ throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, 'One or more products do not exist.');
138
+ }
139
+ const businessId = await this.generateBusinessId(em);
140
+ const rfq = em.create(QuoteRequest, {
141
+ ...(businessId ? { businessId } : {}),
142
+ organizationId: ctx.organizationId,
143
+ customerAccountId: ctx.customerAccountId,
144
+ salesChannelId: raisedOnChannelId(),
145
+ status: 'Pending',
146
+ headerNote: input.headerNote ?? null,
147
+ submittedAt: new Date(),
148
+ currentRevisionNumber: 1,
149
+ lastCustomerSeenRevisionNumber: 1,
150
+ });
151
+ await em.persistAndFlush(rfq);
152
+ const items = [];
153
+ for (const lineInput of input.items) {
154
+ const product = productById.get(lineInput.productId);
155
+ // Feature 043 — append the packaging-unit name to the snapshot name so
156
+ // RFQ views show "<name> (Paleta)", and persist the structured snapshot.
157
+ const baseName = anyLocaleValue(product.name);
158
+ const productName = lineInput.packagingUnitName
159
+ ? `${baseName} (${lineInput.packagingUnitName})`
160
+ : baseName;
161
+ const item = em.create(QuoteRequestItem, {
162
+ quoteRequestId: rfq.id,
163
+ productId: product.id,
164
+ productName,
165
+ productSlug: product.slug,
166
+ variantId: lineInput.variantId ?? null,
167
+ quantity: lineInput.quantity,
168
+ ...(lineInput.packagingUnitName
169
+ ? { packagingUnitName: lineInput.packagingUnitName }
170
+ : {}),
171
+ ...(lineInput.packagingUnitBaseQuantity != null
172
+ ? { packagingUnitBaseQuantity: lineInput.packagingUnitBaseQuantity }
173
+ : {}),
174
+ desiredUnitPrice: lineInput.desiredUnitPrice !== undefined ? lineInput.desiredUnitPrice.toFixed(2) : null,
175
+ lineNote: lineInput.lineNote ?? null,
176
+ lineCurrency: 'PLN',
177
+ });
178
+ items.push(item);
179
+ }
180
+ this.#audit(em, 'quote_request.create', rfq.id, null, {
181
+ status: rfq.status,
182
+ itemCount: items.length,
183
+ salesChannelId: rfq.salesChannelId ?? null,
184
+ });
185
+ await em.persistAndFlush(items);
186
+ const revision = await this.deps.revisionService.record({
187
+ rfq,
188
+ items,
189
+ actor: { customerAccountId: ctx.customerAccountId },
190
+ });
191
+ const created = await this.deps.eventService.append({
192
+ quoteRequestId: rfq.id,
193
+ eventType: 'created',
194
+ actor: { customerAccountId: ctx.customerAccountId, roleLabel: 'Customer' },
195
+ payload: { type: 'created' },
196
+ revisionId: revision.id,
197
+ });
198
+ const submitted = await this.deps.eventService.append({
199
+ quoteRequestId: rfq.id,
200
+ eventType: 'submitted',
201
+ actor: { customerAccountId: ctx.customerAccountId, roleLabel: 'Customer' },
202
+ payload: {
203
+ type: 'submitted',
204
+ itemCount: items.length,
205
+ },
206
+ revisionId: revision.id,
207
+ });
208
+ await this.notifyInternalSide(rfq.organizationId, submitted.id, rfq.id);
209
+ this.deps.events.emit('rfq.created.v1', {
210
+ eventId: randomUUID(),
211
+ occurredAt: new Date().toISOString(),
212
+ rfqId: rfq.id,
213
+ organizationId: rfq.organizationId,
214
+ });
215
+ void created; // referenced for ordering side-effect
216
+ return this.serializeFull(em, rfq, false);
217
+ }
218
+ // -------------------------------------------------------------------------
219
+ // Customer-side patch on a Pending RFQ that no internal user has touched yet
220
+ // -------------------------------------------------------------------------
221
+ async patchDraft(rfqId, ctx, body, expectedVersion) {
222
+ const em = this.deps.emFactory();
223
+ const rfq = await this.findVisibleForCustomer(em, rfqId, ctx);
224
+ if (rfq.status !== 'Pending') {
225
+ throw new HttpError(409, ERROR_CODES.RFQ_NOT_DRAFT, 'Quote Request is not editable.');
226
+ }
227
+ // Reject if any modify event has been written by an admin user — the
228
+ // customer must use accept-revision/reject-revision instead.
229
+ const adminTouched = await em.findOne(QuoteRequestEvent, {
230
+ quoteRequestId: rfq.id,
231
+ eventType: 'modified',
232
+ });
233
+ if (adminTouched) {
234
+ throw new HttpError(409, ERROR_CODES.RFQ_NOT_DRAFT, 'Quote Request is in admin review; resubmit instead of editing.');
235
+ }
236
+ if (expectedVersion !== null && rfq.version !== expectedVersion) {
237
+ throw new HttpError(409, ERROR_CODES.VERSION_CONFLICT, 'Quote Request was updated concurrently.');
238
+ }
239
+ if (body.headerNote !== undefined)
240
+ rfq.headerNote = body.headerNote ?? null;
241
+ if (body.items) {
242
+ const productById = await this.#acquirableProductsById(body.items.map((it) => it.productId), ctx);
243
+ if (productById.size !== new Set(body.items.map((it) => it.productId)).size) {
244
+ // Issues #227 and #259 — see `createForCustomer`. A revision may not
245
+ // add a line the original submission could not have carried, on either
246
+ // axis.
247
+ throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, 'One or more products do not exist.');
248
+ }
249
+ const existingItems = await em.find(QuoteRequestItem, { quoteRequestId: rfq.id });
250
+ await em.removeAndFlush(existingItems);
251
+ const items = [];
252
+ for (const lineInput of body.items) {
253
+ const product = productById.get(lineInput.productId);
254
+ items.push(em.create(QuoteRequestItem, {
255
+ quoteRequestId: rfq.id,
256
+ productId: product.id,
257
+ productName: anyLocaleValue(product.name),
258
+ productSlug: product.slug,
259
+ variantId: lineInput.variantId ?? null,
260
+ quantity: lineInput.quantity,
261
+ desiredUnitPrice: lineInput.desiredUnitPrice !== undefined
262
+ ? lineInput.desiredUnitPrice.toFixed(2)
263
+ : null,
264
+ lineNote: lineInput.lineNote ?? null,
265
+ lineCurrency: 'PLN',
266
+ }));
267
+ }
268
+ await em.persistAndFlush(items);
269
+ }
270
+ rfq.currentRevisionNumber += 1;
271
+ rfq.lastCustomerSeenRevisionNumber = rfq.currentRevisionNumber;
272
+ rfq.version += 1;
273
+ this.#audit(em, 'quote_request.patch_draft', rfq.id, null, {
274
+ currentRevisionNumber: rfq.currentRevisionNumber,
275
+ });
276
+ await em.flush();
277
+ const items = await em.find(QuoteRequestItem, { quoteRequestId: rfq.id });
278
+ const previousRevision = await this.deps.revisionService.byNumber(rfq.id, rfq.currentRevisionNumber - 1);
279
+ const revision = await this.deps.revisionService.record({
280
+ rfq,
281
+ items,
282
+ actor: { customerAccountId: ctx.customerAccountId },
283
+ previousRevisionId: previousRevision?.id ?? null,
284
+ });
285
+ const diff = previousRevision
286
+ ? this.deps.revisionService.diffRevisions({
287
+ beforeHeaderNote: previousRevision.headerNoteSnapshot ?? null,
288
+ afterHeaderNote: rfq.headerNote ?? null,
289
+ beforeItems: previousRevision.itemsSnapshot,
290
+ afterItems: revision.itemsSnapshot,
291
+ })
292
+ : [];
293
+ const evt = await this.deps.eventService.append({
294
+ quoteRequestId: rfq.id,
295
+ eventType: 'modified',
296
+ actor: { customerAccountId: ctx.customerAccountId, roleLabel: 'Customer' },
297
+ payload: { type: 'modified', diff },
298
+ revisionId: revision.id,
299
+ });
300
+ void evt;
301
+ return this.serializeFull(em, rfq, false);
302
+ }
303
+ // -------------------------------------------------------------------------
304
+ // Resubmit (US1) — clones the original at the customer's current price list
305
+ // -------------------------------------------------------------------------
306
+ async resubmit(rfqId, ctx, body) {
307
+ const em = this.deps.emFactory();
308
+ const sourceRfq = await this.findVisibleForCustomer(em, rfqId, ctx);
309
+ const sourceItems = await em.find(QuoteRequestItem, { quoteRequestId: sourceRfq.id });
310
+ if (sourceItems.length === 0) {
311
+ throw new HttpError(400, ERROR_CODES.RFQ_EMPTY, 'Source RFQ has no items.');
312
+ }
313
+ // Issue #259 — a resubmit is raised on *this* request's channel, not the
314
+ // source RFQ's (see `salesChannelId` below), so without this it is the
315
+ // one-hop bypass of every other seam on this list: raise a quote on the
316
+ // channel that publishes the product, resubmit it with another channel's
317
+ // header, and the line arrives in a pipeline that does not sell it. Copying
318
+ // the lines is what makes it invisible — nothing here names a product id
319
+ // the caller supplied.
320
+ //
321
+ // Whole-request 404, the same answer `createForCustomer` gives, because a
322
+ // resubmit *is* a create over lines the buyer already chose.
323
+ const publishedHere = await productIdsInRequestChannel(this.deps.channelMembership, sourceItems.map((it) => it.productId));
324
+ if (publishedHere && sourceItems.some((it) => !publishedHere.has(it.productId))) {
325
+ throw new HttpError(404, ERROR_CODES.PRODUCT_NOT_FOUND, 'One or more products do not exist.');
326
+ }
327
+ const newRfq = em.create(QuoteRequest, {
328
+ organizationId: ctx.organizationId,
329
+ customerAccountId: ctx.customerAccountId,
330
+ // The resubmit is its own request, so it is raised on the channel *this*
331
+ // request resolved to — not the one the source RFQ carries. A buyer who
332
+ // asks again from a different storefront asked from there.
333
+ salesChannelId: raisedOnChannelId(),
334
+ status: 'Pending',
335
+ headerNote: body.headerNote ?? null,
336
+ submittedAt: new Date(),
337
+ currentRevisionNumber: 1,
338
+ lastCustomerSeenRevisionNumber: 1,
339
+ });
340
+ await em.persistAndFlush(newRfq);
341
+ const newItems = sourceItems.map((src) => em.create(QuoteRequestItem, {
342
+ quoteRequestId: newRfq.id,
343
+ productId: src.productId,
344
+ productName: src.productName,
345
+ productSlug: src.productSlug ?? null,
346
+ variantId: src.variantId ?? null,
347
+ quantity: src.quantity,
348
+ desiredUnitPrice: null,
349
+ lineNote: src.lineNote ?? null,
350
+ lineCurrency: src.lineCurrency,
351
+ }));
352
+ this.#audit(em, 'quote_request.resubmit', newRfq.id, null, {
353
+ status: newRfq.status,
354
+ itemCount: newItems.length,
355
+ salesChannelId: newRfq.salesChannelId ?? null,
356
+ });
357
+ await em.persistAndFlush(newItems);
358
+ const revision = await this.deps.revisionService.record({
359
+ rfq: newRfq,
360
+ items: newItems,
361
+ actor: { customerAccountId: ctx.customerAccountId },
362
+ });
363
+ await this.deps.eventService.append({
364
+ quoteRequestId: newRfq.id,
365
+ eventType: 'created',
366
+ actor: { customerAccountId: ctx.customerAccountId, roleLabel: 'Customer' },
367
+ payload: { type: 'created', sourceRfqId: sourceRfq.id },
368
+ revisionId: revision.id,
369
+ });
370
+ const submittedEvt = await this.deps.eventService.append({
371
+ quoteRequestId: newRfq.id,
372
+ eventType: 're-submitted',
373
+ actor: { customerAccountId: ctx.customerAccountId, roleLabel: 'Customer' },
374
+ payload: { type: 're-submitted', sourceRfqId: sourceRfq.id, newRfqId: newRfq.id },
375
+ revisionId: revision.id,
376
+ });
377
+ await this.notifyInternalSide(newRfq.organizationId, submittedEvt.id, newRfq.id);
378
+ return this.serializeFull(em, newRfq, false);
379
+ }
380
+ // -------------------------------------------------------------------------
381
+ // Accept / reject revision (US3 — also handles Created from admin per US4)
382
+ // -------------------------------------------------------------------------
383
+ async acceptRevision(rfqId, ctx, expectedRevisionNumber) {
384
+ return this.respondToRevision(rfqId, ctx, expectedRevisionNumber, 'accept');
385
+ }
386
+ async rejectRevision(rfqId, ctx, expectedRevisionNumber, reason) {
387
+ return this.respondToRevision(rfqId, ctx, expectedRevisionNumber, 'reject', reason);
388
+ }
389
+ async respondToRevision(rfqId, ctx, expectedRevisionNumber, decision, reason) {
390
+ const em = this.deps.emFactory();
391
+ const rfq = await this.findVisibleForCustomer(em, rfqId, ctx);
392
+ if (rfq.status !== 'Pending' && rfq.status !== 'Created from admin') {
393
+ throw new HttpError(409, ERROR_CODES.RFQ_NOT_QUOTED, 'Quote Request is not awaiting your decision.');
394
+ }
395
+ // The offer the operator dated stops being acceptable on the date both
396
+ // parties were shown — restored 2026-08-29, see `validityHasLapsed`.
397
+ //
398
+ // **Only the accept half.** Declining a lapsed offer consumes no price the
399
+ // seller committed to, and refusing it would leave the buyer no way to
400
+ // clear a row they have already been told is dead. Their other way forward,
401
+ // `resubmit`, raises a new request at current prices and copies no
402
+ // deadline; neither is reached by this rule.
403
+ //
404
+ // Ahead of the revision-number CAS deliberately: once the deadline has
405
+ // passed, answering `VERSION_CONFLICT` — "refresh and try again" — would
406
+ // send the buyer round a loop that has no way out.
407
+ if (decision === 'accept' && validityHasLapsed(rfq, Date.now())) {
408
+ throw new HttpError(410, ERROR_CODES.RFQ_EXPIRED, 'This quote is no longer valid — its validity period has ended.');
409
+ }
410
+ if (rfq.currentRevisionNumber !== expectedRevisionNumber) {
411
+ throw new HttpError(409, ERROR_CODES.VERSION_CONFLICT, 'Quote Request was revised again — refresh and try again.');
412
+ }
413
+ const now = new Date();
414
+ if (decision === 'accept') {
415
+ rfq.status = 'Approved';
416
+ rfq.approvedAt = now;
417
+ }
418
+ else {
419
+ rfq.status = 'Canceled';
420
+ rfq.canceledAt = now;
421
+ if (reason)
422
+ rfq.cancellationReason = reason;
423
+ }
424
+ rfq.awaitingCustomerRevisionAcceptance = false;
425
+ rfq.lastCustomerSeenRevisionNumber = rfq.currentRevisionNumber;
426
+ rfq.version += 1;
427
+ this.#audit(em, 'quote_request.respond_to_revision', rfq.id, null, {
428
+ decision,
429
+ status: rfq.status,
430
+ revisionNumber: rfq.currentRevisionNumber,
431
+ });
432
+ await em.flush();
433
+ const eventType = decision === 'accept' ? 'customer-accepted-revision' : 'customer-rejected-revision';
434
+ const eventPayload = {
435
+ type: eventType,
436
+ revisionNumber: rfq.currentRevisionNumber,
437
+ };
438
+ if (decision === 'reject' && reason)
439
+ eventPayload['reason'] = reason;
440
+ const revisionEvt = await this.deps.eventService.append({
441
+ quoteRequestId: rfq.id,
442
+ eventType,
443
+ actor: { customerAccountId: ctx.customerAccountId, roleLabel: 'Customer' },
444
+ payload: eventPayload,
445
+ });
446
+ const terminalEventType = decision === 'accept' ? 'approved' : 'canceled';
447
+ const terminalPayload = decision === 'accept'
448
+ ? { type: 'approved', approvedBy: 'customer' }
449
+ : { type: 'canceled', canceledBy: 'customer', reason: reason ?? null };
450
+ const terminalEvt = await this.deps.eventService.append({
451
+ quoteRequestId: rfq.id,
452
+ eventType: terminalEventType,
453
+ actor: { customerAccountId: ctx.customerAccountId, roleLabel: 'Customer' },
454
+ payload: terminalPayload,
455
+ });
456
+ void revisionEvt;
457
+ await this.notifyInternalSide(rfq.organizationId, terminalEvt.id, rfq.id);
458
+ if (decision === 'accept') {
459
+ this.deps.events.emit('rfq.approved.v1', {
460
+ eventId: randomUUID(),
461
+ occurredAt: now.toISOString(),
462
+ rfqId: rfq.id,
463
+ });
464
+ }
465
+ else {
466
+ this.deps.events.emit('rfq.canceled.v1', {
467
+ eventId: randomUUID(),
468
+ occurredAt: now.toISOString(),
469
+ rfqId: rfq.id,
470
+ reason: reason ?? null,
471
+ });
472
+ }
473
+ return this.serializeFull(em, rfq, false);
474
+ }
475
+ // -------------------------------------------------------------------------
476
+ // Convert to order (US5)
477
+ // -------------------------------------------------------------------------
478
+ async convertToOrder(rfqId, ctx) {
479
+ const em = this.deps.emFactory();
480
+ const rfq = await this.findVisibleForCustomer(em, rfqId, ctx);
481
+ if (rfq.status !== 'Approved') {
482
+ throw new HttpError(409, ERROR_CODES.RFQ_NOT_QUOTED, 'Quote Request is not approved.');
483
+ }
484
+ // The seller's price commitment is *consumed* here, not at acceptance:
485
+ // this is the call that seeds a cart at `agreedUnitPrice`, which `orders`
486
+ // then copies onto the order line without recomputing. So the deadline
487
+ // binds here too, and this is the site that made "expires <date>" a
488
+ // sentence with no consequence — an accepted quote is `Approved`, and the
489
+ // expiry worker sweeps only `Pending` / `Created from admin`, so nothing
490
+ // in the platform ever reached this row again.
491
+ //
492
+ // `QUOTE_VALIDITY_ENDED` rather than `RFQ_EXPIRED`: the buyer here already
493
+ // made their decision inside the window and it is the *quote* that has run
494
+ // out, which is a different sentence and a different remedy from an offer
495
+ // that lapsed undecided. The split is
496
+ // `specs/001-b2b-platform-foundation/contracts/quote_requests.contract.md`'s
497
+ // own — `410 RFQ_EXPIRED` on accept, `410 QUOTE_VALIDITY_ENDED` on
498
+ // convert — restored rather than invented.
499
+ if (validityHasLapsed(rfq, Date.now())) {
500
+ throw new HttpError(410, ERROR_CODES.QUOTE_VALIDITY_ENDED, 'The accepted quote is no longer valid — its validity period has ended.');
501
+ }
502
+ const items = await em.find(QuoteRequestItem, { quoteRequestId: rfq.id });
503
+ if (items.length === 0) {
504
+ throw new HttpError(409, ERROR_CODES.RFQ_EMPTY, 'Quote Request has no items.');
505
+ }
506
+ // Validate every line's product is still resolvable. The spec edge
507
+ // case "product archived between approve and convert" maps to a 409
508
+ // here so the customer is forced to contact the rep.
509
+ //
510
+ // **No sales-channel filter here, by an owner ruling of 2026-08-21**
511
+ // (issue #259) — a decision, not the seam that was missed. The other three
512
+ // write paths in this service grew one in that issue and this one did not,
513
+ // for two reasons:
514
+ //
515
+ // 1. A conversion names **no product the caller supplied**. It re-acquires
516
+ // the lines of a quote an operator *approved*, at the unit prices they
517
+ // agreed — a commitment the seller has already made. `createForCustomer`
518
+ // and `patchDraft` filter because they take ids from the buyer;
519
+ // `resubmit` filters because it raises a **new** quote on the current
520
+ // request's channel. This raises nothing: it hands the agreed lines to
521
+ // `carts.replaceItemsForCustomer`.
522
+ // 2. Refusing would strand the buyer. An approved quote they cannot act on
523
+ // is worse than one converted on a storefront that does not list the
524
+ // line, and the only channel this operation could honestly be judged
525
+ // against is the one the quote was raised on (`rfq.salesChannelId`),
526
+ // which the buyer has no way to switch back to from here.
527
+ //
528
+ // The sibling ruling covers `orders`' reorder, which re-acquires a placed
529
+ // order's lines through the same port; its comment carries the second-order
530
+ // consequence that makes the obvious fix wrong there.
531
+ const productIds = items.map((it) => it.productId);
532
+ const products = await this.deps.catalogProducts.findByIds(productIds);
533
+ if (products.length !== new Set(productIds).size) {
534
+ throw new HttpError(409, ERROR_CODES.PRODUCT_NOT_FOUND, 'One or more Quote Request line products are no longer available.');
535
+ }
536
+ // Get-or-create the customer's active cart, clear it, and reseed at the
537
+ // RFQ's agreed unit prices. The orders module copies `CartItem.unitPrice`
538
+ // straight onto `OrderItem.unitPrice` (no recomputation), so the negotiated
539
+ // price flows through unchanged.
540
+ //
541
+ // Feature 075, Phase C — one port call where this module used to create a
542
+ // `Cart` and hand-build `CartItem` rows in `carts`' tables. The
543
+ // clear-then-seed rule and the `lastActivityAt` bookkeeping now live once,
544
+ // inside the module that owns them; `carts` published
545
+ // `replaceItemsForCustomer` naming this call site.
546
+ const seeded = await this.deps.carts.replaceItemsForCustomer({
547
+ customerAccountId: ctx.customerAccountId,
548
+ organizationId: ctx.organizationId,
549
+ }, items.map((it) => ({
550
+ productId: it.productId,
551
+ ...(it.variantId ? { variantId: it.variantId } : {}),
552
+ quantity: it.quantity,
553
+ unitPrice: (it.agreedUnitPrice ?? '0').toString(),
554
+ currency: it.lineCurrency,
555
+ })));
556
+ const cartId = seeded.cart.id;
557
+ this.#audit(em, 'quote_request.convert_to_order', rfq.id, null, {
558
+ cartId,
559
+ itemCount: seeded.items.length,
560
+ });
561
+ await em.flush();
562
+ return {
563
+ cartId,
564
+ checkoutUrl: `/checkout?cartId=${cartId}&fromRfq=${rfq.id}`,
565
+ };
566
+ }
567
+ // -------------------------------------------------------------------------
568
+ // Lookup with visibility check
569
+ // -------------------------------------------------------------------------
570
+ async findVisibleForCustomer(em, rfqId, ctx) {
571
+ const rfq = await em.findOne(QuoteRequest, { id: rfqId });
572
+ if (!rfq)
573
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Quote Request not found.');
574
+ if (rfq.organizationId !== ctx.organizationId) {
575
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Quote Request not found.');
576
+ }
577
+ if (!ctx.isOrgAdmin && rfq.customerAccountId !== ctx.customerAccountId) {
578
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Quote Request not found.');
579
+ }
580
+ return rfq;
581
+ }
582
+ // -------------------------------------------------------------------------
583
+ // Notification helpers
584
+ // -------------------------------------------------------------------------
585
+ async notifyInternalSide(organizationId, sourceEventId, quoteRequestId) {
586
+ // Find every admin user assigned to the org. If the org is unassigned,
587
+ // fan out to every active admin user holding the sales_representative
588
+ // role plus every platform_admin (research §R4).
589
+ //
590
+ // Feature 075, Phase C — this asked the question twice: an `em.count` on
591
+ // `organizations`' assignment entity, reached through a dynamic `import()`
592
+ // with a comment apologising for it, and then the port. The count was a
593
+ // duplicate of `listForOrganization(…).length`, and its `.catch(() => 0)`
594
+ // silently answered "unassigned" — a fan-out to every admin — for any
595
+ // failure at all, `MODULE_DISABLED` included. One port call, no catch, and
596
+ // an absent `organizations` now stops the notification instead of
597
+ // broadcasting it.
598
+ const assignments = await this.deps.salesRepAssignment.listForOrganization(organizationId);
599
+ const recipients = [];
600
+ if (assignments.length > 0) {
601
+ for (const a of assignments)
602
+ recipients.push({ adminUserId: a.adminUserId });
603
+ }
604
+ else {
605
+ const everyAdmin = await this.deps.adminUsers.listAll();
606
+ for (const a of everyAdmin)
607
+ recipients.push({ adminUserId: a.id });
608
+ }
609
+ await this.deps.notificationService.enqueue({
610
+ quoteRequestId,
611
+ sourceEventId,
612
+ recipients,
613
+ channels: ['email', 'in_app'],
614
+ });
615
+ }
616
+ // -------------------------------------------------------------------------
617
+ // Serialisation
618
+ // -------------------------------------------------------------------------
619
+ /** Serialise to the customer-facing response shape. Hides internal admin
620
+ * actor identity behind `actorRoleLabel` per FR-037 unless
621
+ * `includeFullActorIdentity = true` (admin path). */
622
+ async serializeFull(em, rfq, includeFullActorIdentity) {
623
+ const items = await em.find(QuoteRequestItem, { quoteRequestId: rfq.id });
624
+ const taxRate = await this.taxRateForOrganization(rfq.organizationId);
625
+ const events = await em.find(QuoteRequestEvent, { quoteRequestId: rfq.id }, { orderBy: { createdAt: 'asc' } });
626
+ let comparison = null;
627
+ if (rfq.awaitingCustomerRevisionAcceptance) {
628
+ const last = await this.deps.revisionService.byNumber(rfq.id, rfq.lastCustomerSeenRevisionNumber);
629
+ const current = await this.deps.revisionService.byNumber(rfq.id, rfq.currentRevisionNumber);
630
+ if (current) {
631
+ const beforeItems = last?.itemsSnapshot ?? [];
632
+ const beforeHeaderNote = last?.headerNoteSnapshot ?? null;
633
+ comparison = {
634
+ diff: this.deps.revisionService.diffRevisions({
635
+ beforeHeaderNote,
636
+ afterHeaderNote: current.headerNoteSnapshot ?? null,
637
+ beforeItems,
638
+ afterItems: current.itemsSnapshot,
639
+ }),
640
+ lastSeenRevisionNumber: rfq.lastCustomerSeenRevisionNumber,
641
+ currentRevisionNumber: rfq.currentRevisionNumber,
642
+ };
643
+ }
644
+ }
645
+ return {
646
+ id: rfq.id,
647
+ businessId: rfq.businessId,
648
+ organizationId: rfq.organizationId,
649
+ customerAccountId: rfq.customerAccountId,
650
+ createdByAdminUserId: rfq.createdByAdminUserId ?? null,
651
+ assignedAdminUserId: rfq.assignedAdminUserId ?? null,
652
+ status: rfq.status,
653
+ awaitingCustomerRevisionAcceptance: rfq.awaitingCustomerRevisionAcceptance,
654
+ currentRevisionNumber: rfq.currentRevisionNumber,
655
+ lastCustomerSeenRevisionNumber: rfq.lastCustomerSeenRevisionNumber,
656
+ headerNote: rfq.headerNote ?? null,
657
+ cancellationReason: rfq.cancellationReason ?? null,
658
+ customFieldValues: rfq.customFieldValues ?? {},
659
+ items: items.map((it) => ({
660
+ id: it.id,
661
+ productId: it.productId,
662
+ productName: it.productName,
663
+ productSlug: it.productSlug ?? null,
664
+ variantId: it.variantId ?? null,
665
+ variantLabel: it.variantLabel ?? null,
666
+ quantity: it.quantity,
667
+ desiredUnitPrice: it.desiredUnitPrice != null ? Number(it.desiredUnitPrice) : null,
668
+ agreedUnitPrice: it.agreedUnitPrice != null ? Number(it.agreedUnitPrice) : null,
669
+ lineNote: it.lineNote ?? null,
670
+ lineCurrency: it.lineCurrency,
671
+ discountPercent: it.discountPercent != null ? Number(it.discountPercent) : null,
672
+ taxRate,
673
+ })),
674
+ events: events.map((e) => ({
675
+ id: e.id,
676
+ eventType: e.eventType,
677
+ actorAdminUserId: includeFullActorIdentity ? e.actorAdminUserId ?? null : null,
678
+ actorCustomerAccountId: e.actorCustomerAccountId ?? null,
679
+ actorRoleLabel: e.actorRoleLabel ?? null,
680
+ payload: e.payload,
681
+ revisionId: e.revisionId ?? null,
682
+ createdAt: e.createdAt.toISOString(),
683
+ })),
684
+ comparisonAgainstLastSeen: comparison,
685
+ submittedAt: rfq.submittedAt?.toISOString() ?? null,
686
+ approvedAt: rfq.approvedAt?.toISOString() ?? null,
687
+ canceledAt: rfq.canceledAt?.toISOString() ?? null,
688
+ completedAt: rfq.completedAt?.toISOString() ?? null,
689
+ expiredAt: rfq.expiredAt?.toISOString() ?? null,
690
+ expiresAt: rfq.expiresAt?.toISOString() ?? null,
691
+ convertedOrderId: rfq.convertedOrderId ?? null,
692
+ taxRate,
693
+ createdAt: rfq.createdAt.toISOString(),
694
+ updatedAt: rfq.updatedAt.toISOString(),
695
+ version: rfq.version,
696
+ };
697
+ }
698
+ }
699
+ // ---------------------------------------------------------------------------
700
+ // Helpers
701
+ // ---------------------------------------------------------------------------
702
+ function anyLocaleValue(blob) {
703
+ const k = Object.keys(blob)[0];
704
+ return k ? (blob[k] ?? '') : '';
705
+ }
706
+ /**
707
+ * Has the validity deadline an operator set on *this* request passed?
708
+ *
709
+ * `expiresAt` is written by `RfqAdminService.modify` / `createOnBehalf` from
710
+ * the operator's `expiresInDays`, is serialised into the admin's `RfqDetail`
711
+ * and into the storefront's types, and both parties are shown
712
+ * `expires <date>`. Between the feature-008 workflow rewrite (`4f24dc948`,
713
+ * which dropped the live check `accept()` carried) and its restoration on
714
+ * 2026-08-29 it was read by no rule at all.
715
+ *
716
+ * **This is not the expiry worker's concept.** `RfqExpiryWorker` sweeps
717
+ * `updatedAt` against a settings-wide `expiryDays` and flips `Pending` /
718
+ * `Created from admin` rows to `Expired`; it never reads `expiresAt` and
719
+ * never reaches `Approved`. The original comment called this the check that
720
+ * "covers the window before the expiry worker runs" — for an approved quote
721
+ * that window never closes, because no sweep reaches it.
722
+ *
723
+ * Absent means the operator set no deadline and answers `false`: the rule
724
+ * binds a date somebody chose, never a default of this module's choosing.
725
+ */
726
+ function validityHasLapsed(rfq, nowMs) {
727
+ return rfq.expiresAt != null && rfq.expiresAt.getTime() <= nowMs;
728
+ }
729
+ function customerDisplayName(c) {
730
+ return [c.firstName, c.lastName].filter(Boolean).join(' ') || c.email;
731
+ }
732
+ function summarize(rfq, items, extras) {
733
+ const totalAtCustomerPrice = items.reduce((sum, it) => {
734
+ const desired = it.desiredUnitPrice != null ? Number(it.desiredUnitPrice) : null;
735
+ return desired != null ? sum + desired * it.quantity : sum;
736
+ }, 0);
737
+ const totalAtAgreedPrice = items.every((it) => it.agreedUnitPrice != null)
738
+ ? items.reduce((sum, it) => sum + Number(it.agreedUnitPrice ?? 0) * it.quantity, 0)
739
+ : null;
740
+ const currency = items[0]?.lineCurrency ?? 'PLN';
741
+ return {
742
+ id: rfq.id,
743
+ businessId: rfq.businessId,
744
+ organizationId: rfq.organizationId,
745
+ customerAccountId: rfq.customerAccountId,
746
+ status: rfq.status,
747
+ awaitingCustomerRevisionAcceptance: rfq.awaitingCustomerRevisionAcceptance,
748
+ lineCount: items.length,
749
+ totalAtCustomerPrice: items.length > 0 ? totalAtCustomerPrice : null,
750
+ totalAtAgreedPrice,
751
+ taxRate: extras.taxRate ?? 0,
752
+ currency,
753
+ submittedAt: rfq.submittedAt?.toISOString() ?? null,
754
+ expiresAt: rfq.expiresAt?.toISOString() ?? null,
755
+ createdAt: rfq.createdAt.toISOString(),
756
+ updatedAt: rfq.updatedAt.toISOString(),
757
+ version: rfq.version,
758
+ originalRequester: {
759
+ customerAccountId: rfq.customerAccountId,
760
+ displayName: extras.requesterDisplayName ?? null,
761
+ },
762
+ };
763
+ }
764
+ function groupBy(arr, key) {
765
+ const out = new Map();
766
+ for (const v of arr) {
767
+ const k = key(v);
768
+ const existing = out.get(k);
769
+ if (existing)
770
+ existing.push(v);
771
+ else
772
+ out.set(k, [v]);
773
+ }
774
+ return out;
775
+ }
776
+ /**
777
+ * The {@link ProductAudience} an RFQ line speaks for (issue #227).
778
+ *
779
+ * A quote request is always a signed-in buyer's, and its `organizationId` is
780
+ * the one the RFQ is filed under — so it is also the one the product's
781
+ * allow-list has to name.
782
+ */
783
+ function rfqAudience(ctx) {
784
+ return { organizationId: ctx.organizationId, authenticated: true };
785
+ }
786
+ //# sourceMappingURL=rfq-service.js.map