@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.
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/admin/index.d.ts +26 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +56 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/RfqCreatePage.d.ts +9 -0
- package/dist/admin/pages/RfqCreatePage.d.ts.map +1 -0
- package/dist/admin/pages/RfqCreatePage.js +163 -0
- package/dist/admin/pages/RfqCreatePage.js.map +1 -0
- package/dist/admin/pages/RfqDetail.d.ts +9 -0
- package/dist/admin/pages/RfqDetail.d.ts.map +1 -0
- package/dist/admin/pages/RfqDetail.js +399 -0
- package/dist/admin/pages/RfqDetail.js.map +1 -0
- package/dist/admin/pages/RfqList.d.ts +9 -0
- package/dist/admin/pages/RfqList.d.ts.map +1 -0
- package/dist/admin/pages/RfqList.js +214 -0
- package/dist/admin/pages/RfqList.js.map +1 -0
- package/dist/admin/validity.d.ts +52 -0
- package/dist/admin/validity.d.ts.map +1 -0
- package/dist/admin/validity.js +61 -0
- package/dist/admin/validity.js.map +1 -0
- package/dist/backend/entities/quote-request-event.entity.d.ts +24 -0
- package/dist/backend/entities/quote-request-event.entity.d.ts.map +1 -0
- package/dist/backend/entities/quote-request-event.entity.js +67 -0
- package/dist/backend/entities/quote-request-event.entity.js.map +1 -0
- package/dist/backend/entities/quote-request-item.entity.d.ts +34 -0
- package/dist/backend/entities/quote-request-item.entity.d.ts.map +1 -0
- package/dist/backend/entities/quote-request-item.entity.js +119 -0
- package/dist/backend/entities/quote-request-item.entity.js.map +1 -0
- package/dist/backend/entities/quote-request-notification-event.entity.d.ts +23 -0
- package/dist/backend/entities/quote-request-notification-event.entity.d.ts.map +1 -0
- package/dist/backend/entities/quote-request-notification-event.entity.js +72 -0
- package/dist/backend/entities/quote-request-notification-event.entity.js.map +1 -0
- package/dist/backend/entities/quote-request-revision.entity.d.ts +35 -0
- package/dist/backend/entities/quote-request-revision.entity.d.ts.map +1 -0
- package/dist/backend/entities/quote-request-revision.entity.js +67 -0
- package/dist/backend/entities/quote-request-revision.entity.js.map +1 -0
- package/dist/backend/entities/quote-request.entity.d.ts +90 -0
- package/dist/backend/entities/quote-request.entity.d.ts.map +1 -0
- package/dist/backend/entities/quote-request.entity.js +191 -0
- package/dist/backend/entities/quote-request.entity.js.map +1 -0
- package/dist/backend/index.d.ts +120 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +361 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/plugin.d.ts +95 -0
- package/dist/backend/plugin.d.ts.map +1 -0
- package/dist/backend/plugin.js +152 -0
- package/dist/backend/plugin.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +13 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +82 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.customer.d.ts +18 -0
- package/dist/backend/routes.customer.d.ts.map +1 -0
- package/dist/backend/routes.customer.js +98 -0
- package/dist/backend/routes.customer.js.map +1 -0
- package/dist/backend/routes.sales-reps.d.ts +12 -0
- package/dist/backend/routes.sales-reps.d.ts.map +1 -0
- package/dist/backend/routes.sales-reps.js +57 -0
- package/dist/backend/routes.sales-reps.js.map +1 -0
- package/dist/backend/services/order-completion-reactor.d.ts +32 -0
- package/dist/backend/services/order-completion-reactor.d.ts.map +1 -0
- package/dist/backend/services/order-completion-reactor.js +57 -0
- package/dist/backend/services/order-completion-reactor.js.map +1 -0
- package/dist/backend/services/quote-request-business-id-generator.d.ts +46 -0
- package/dist/backend/services/quote-request-business-id-generator.d.ts.map +1 -0
- package/dist/backend/services/quote-request-business-id-generator.js +69 -0
- package/dist/backend/services/quote-request-business-id-generator.js.map +1 -0
- package/dist/backend/services/quote-request-read-port.d.ts +36 -0
- package/dist/backend/services/quote-request-read-port.d.ts.map +1 -0
- package/dist/backend/services/quote-request-read-port.js +96 -0
- package/dist/backend/services/quote-request-read-port.js.map +1 -0
- package/dist/backend/services/raised-on-channel.d.ts +23 -0
- package/dist/backend/services/raised-on-channel.d.ts.map +1 -0
- package/dist/backend/services/raised-on-channel.js +26 -0
- package/dist/backend/services/raised-on-channel.js.map +1 -0
- package/dist/backend/services/rfq-admin-service.d.ts +75 -0
- package/dist/backend/services/rfq-admin-service.d.ts.map +1 -0
- package/dist/backend/services/rfq-admin-service.js +487 -0
- package/dist/backend/services/rfq-admin-service.js.map +1 -0
- package/dist/backend/services/rfq-event-service.d.ts +32 -0
- package/dist/backend/services/rfq-event-service.d.ts.map +1 -0
- package/dist/backend/services/rfq-event-service.js +28 -0
- package/dist/backend/services/rfq-event-service.js.map +1 -0
- package/dist/backend/services/rfq-expiry-worker.d.ts +50 -0
- package/dist/backend/services/rfq-expiry-worker.d.ts.map +1 -0
- package/dist/backend/services/rfq-expiry-worker.js +70 -0
- package/dist/backend/services/rfq-expiry-worker.js.map +1 -0
- package/dist/backend/services/rfq-notification-service.d.ts +36 -0
- package/dist/backend/services/rfq-notification-service.d.ts.map +1 -0
- package/dist/backend/services/rfq-notification-service.js +52 -0
- package/dist/backend/services/rfq-notification-service.js.map +1 -0
- package/dist/backend/services/rfq-revision-service.d.ts +70 -0
- package/dist/backend/services/rfq-revision-service.d.ts.map +1 -0
- package/dist/backend/services/rfq-revision-service.js +120 -0
- package/dist/backend/services/rfq-revision-service.js.map +1 -0
- package/dist/backend/services/rfq-service.d.ts +140 -0
- package/dist/backend/services/rfq-service.d.ts.map +1 -0
- package/dist/backend/services/rfq-service.js +786 -0
- package/dist/backend/services/rfq-service.js.map +1 -0
- package/dist/backend/services/sales-channel-attributions.d.ts +38 -0
- package/dist/backend/services/sales-channel-attributions.d.ts.map +1 -0
- package/dist/backend/services/sales-channel-attributions.js +48 -0
- package/dist/backend/services/sales-channel-attributions.js.map +1 -0
- package/dist/manifest.d.ts +203 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +277 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260424T190112_quote_requests_init.d.ts +19 -0
- package/dist/migrations/20260424T190112_quote_requests_init.d.ts.map +1 -0
- package/dist/migrations/20260424T190112_quote_requests_init.js +65 -0
- package/dist/migrations/20260424T190112_quote_requests_init.js.map +1 -0
- package/dist/migrations/20260503T141344_quote_requests_workflow.d.ts +37 -0
- package/dist/migrations/20260503T141344_quote_requests_workflow.d.ts.map +1 -0
- package/dist/migrations/20260503T141344_quote_requests_workflow.js +251 -0
- package/dist/migrations/20260503T141344_quote_requests_workflow.js.map +1 -0
- package/dist/migrations/20260611T140415_quote_requests_qr_item_packaging.d.ts +10 -0
- package/dist/migrations/20260611T140415_quote_requests_qr_item_packaging.d.ts.map +1 -0
- package/dist/migrations/20260611T140415_quote_requests_qr_item_packaging.js +16 -0
- package/dist/migrations/20260611T140415_quote_requests_qr_item_packaging.js.map +1 -0
- package/dist/migrations/20260611T140417_quote_requests_business_id.d.ts +24 -0
- package/dist/migrations/20260611T140417_quote_requests_business_id.d.ts.map +1 -0
- package/dist/migrations/20260611T140417_quote_requests_business_id.js +34 -0
- package/dist/migrations/20260611T140417_quote_requests_business_id.js.map +1 -0
- package/dist/migrations/20260617T095510_quote_requests_backfill_admin_created_awaiting.d.ts +21 -0
- package/dist/migrations/20260617T095510_quote_requests_backfill_admin_created_awaiting.d.ts.map +1 -0
- package/dist/migrations/20260617T095510_quote_requests_backfill_admin_created_awaiting.js +29 -0
- package/dist/migrations/20260617T095510_quote_requests_backfill_admin_created_awaiting.js.map +1 -0
- package/dist/migrations/20260718T200342_quote_requests_quote_request_custom_field_values.d.ts +12 -0
- package/dist/migrations/20260718T200342_quote_requests_quote_request_custom_field_values.d.ts.map +1 -0
- package/dist/migrations/20260718T200342_quote_requests_quote_request_custom_field_values.js +16 -0
- package/dist/migrations/20260718T200342_quote_requests_quote_request_custom_field_values.js.map +1 -0
- package/dist/migrations/20260912T125614_quote_requests_quote_request_channel_attribution.d.ts +34 -0
- package/dist/migrations/20260912T125614_quote_requests_quote_request_channel_attribution.d.ts.map +1 -0
- package/dist/migrations/20260912T125614_quote_requests_quote_request_channel_attribution.js +57 -0
- package/dist/migrations/20260912T125614_quote_requests_quote_request_channel_attribution.js.map +1 -0
- package/dist/migrations/index.d.ts +53 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +61 -0
- package/dist/migrations/index.js.map +1 -0
- package/dist/migrations/status-mapping.d.ts +18 -0
- package/dist/migrations/status-mapping.d.ts.map +1 -0
- package/dist/migrations/status-mapping.js +28 -0
- package/dist/migrations/status-mapping.js.map +1 -0
- package/docs/quote_requests.md +168 -0
- package/i18n/en.json +14 -0
- package/i18n/pl.json +14 -0
- package/package.json +99 -0
- 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
|