Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dc3c7ce07 | |||
| e764932c39 | |||
| 33b34d08a7 | |||
| 5afda8cb34 |
+243
-39
@@ -127,7 +127,7 @@ All fetch and fetchAll endpoints gate response object keys via `processObjectVal
|
|||||||
| User | `obj.user` | `GET /user/@me`, `GET /user/users/:identifier`, `GET /user/users`, `GET /role/:identifier/users` |
|
| User | `obj.user` | `GET /user/@me`, `GET /user/users/:identifier`, `GET /user/users`, `GET /role/:identifier/users` |
|
||||||
| Role | `obj.role` | `GET /role/:identifier`, `GET /role/`, `GET /user/users/:identifier/roles` |
|
| Role | `obj.role` | `GET /role/:identifier`, `GET /role/`, `GET /user/users/:identifier/roles` |
|
||||||
| Catalog Item | `obj.catalogItem` | `GET /procurement/items`, `GET /procurement/items/:identifier`, `GET /procurement/items/:identifier/linked` |
|
| Catalog Item | `obj.catalogItem` | `GET /procurement/items`, `GET /procurement/items/:identifier`, `GET /procurement/items/:identifier/linked` |
|
||||||
| Opportunity | `obj.opportunity` | `GET /sales/opportunities`, `GET /sales/opportunities/:identifier` |
|
| Opportunity | `obj.opportunity` | `GET /sales/opportunities`, `GET /sales/opportunities/opportunity/:identifier` |
|
||||||
| UniFi Site | `obj.unifiSite` | `GET /unifi/sites`, `GET /unifi/site/:id`, `GET /company/companies/:identifier/unifi/sites` |
|
| UniFi Site | `obj.unifiSite` | `GET /unifi/sites`, `GET /unifi/site/:id`, `GET /company/companies/:identifier/unifi/sites` |
|
||||||
| WiFi Network | `unifi.site.wifi.read` | `GET /unifi/site/:id/wifi` |
|
| WiFi Network | `unifi.site.wifi.read` | `GET /unifi/site/:id/wifi` |
|
||||||
|
|
||||||
@@ -2956,7 +2956,11 @@ Fetch the list of all opportunity quote statuses (types). Returns a static list
|
|||||||
|
|
||||||
**Authentication Required:** Yes
|
**Authentication Required:** Yes
|
||||||
|
|
||||||
**Required Permissions:** `sales.opportunity.fetch.many`
|
**Required Permissions:**
|
||||||
|
|
||||||
|
- Base access: `sales.opportunity.fetch.many`
|
||||||
|
- `scope=all`: `sales.opportunity.metrics.all`
|
||||||
|
- `identifier=<cwIdentifier>` (when different from caller's own identifier): `sales.opportunity.metrics.identifier.override`
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
@@ -3064,6 +3068,7 @@ Fetch a paginated list of opportunities. Supports search. Each opportunity inclu
|
|||||||
"site": { "id": 50, "name": "Main Office" },
|
"site": { "id": 50, "name": "Main Office" },
|
||||||
"customerPO": null,
|
"customerPO": null,
|
||||||
"totalSalesTax": 0,
|
"totalSalesTax": 0,
|
||||||
|
"expectedSalesTax": 0.06,
|
||||||
"probability": 50,
|
"probability": 50,
|
||||||
"location": { "id": 1, "name": "Murray" },
|
"location": { "id": 1, "name": "Murray" },
|
||||||
"department": { "id": 5, "name": "Sales" },
|
"department": { "id": 5, "name": "Sales" },
|
||||||
@@ -3161,9 +3166,198 @@ Get the total number of opportunities.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Get My Opportunities
|
||||||
|
|
||||||
|
**GET** `/sales/opportunities/@me`
|
||||||
|
|
||||||
|
Returns all opportunities where the authenticated user is assigned as the primary or secondary sales rep. The user's ConnectWise member identifier is resolved from their account.
|
||||||
|
|
||||||
|
**Authentication Required:** Yes
|
||||||
|
|
||||||
|
**Required Permissions:** `sales.opportunity.fetch.many`
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
- `includeClosed` _(optional, default `false`)_ — When `true`, includes closed opportunities in the result.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 200,
|
||||||
|
"message": "Opportunities fetched successfully!",
|
||||||
|
"data": [ /* array of opportunity objects */ ],
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
|
||||||
|
- Returns an empty array if the authenticated user has no ConnectWise identifier linked to their account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Opportunities by User ID
|
||||||
|
|
||||||
|
**GET** `/sales/opportunities/user/:id`
|
||||||
|
|
||||||
|
Returns all opportunities where the specified user is assigned as the primary or secondary sales rep.
|
||||||
|
|
||||||
|
**Authentication Required:** Yes
|
||||||
|
|
||||||
|
**Required Permissions:** `sales.opportunity.fetch.many`
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
- `id` — Internal user ID (cuid)
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
- `includeClosed` _(optional, default `false`)_ — When `true`, includes closed opportunities in the result.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 200,
|
||||||
|
"message": "Opportunities fetched successfully!",
|
||||||
|
"data": [ /* array of opportunity objects */ ],
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
|
||||||
|
- `404` — User not found
|
||||||
|
- Returns an empty array if the user has no ConnectWise identifier linked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Sales Opportunity Metrics (Cached)
|
||||||
|
|
||||||
|
**GET** `/sales/opportunities/metrics`
|
||||||
|
|
||||||
|
Fetch precomputed sales opportunity metrics from Redis. Metrics are refreshed in the background every 5 minutes for active ConnectWise members and are designed for fast dashboard/reporting reads.
|
||||||
|
|
||||||
|
**Authentication Required:** Yes
|
||||||
|
|
||||||
|
**Required Permissions:** `sales.opportunity.fetch.many`
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
- `scope` _(optional, default `me`)_ — `me` returns metrics for the current user's `cwIdentifier`; `all` returns cached metrics for all active CW members.
|
||||||
|
- `identifier` _(optional)_ — Explicit CW member identifier override (e.g. `jroberts`). Ignored when `scope=all`.
|
||||||
|
|
||||||
|
**Strict parameter gating:**
|
||||||
|
|
||||||
|
- `scope=all` is rejected with `403` unless caller has `sales.opportunity.metrics.all`.
|
||||||
|
- `identifier=<cwIdentifier>` is rejected with `403` when the identifier differs from caller's own `cwIdentifier` and caller lacks `sales.opportunity.metrics.identifier.override`.
|
||||||
|
|
||||||
|
**Response (`scope=me`):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 200,
|
||||||
|
"message": "Sales opportunity metrics fetched successfully!",
|
||||||
|
"data": {
|
||||||
|
"identifier": "jroberts",
|
||||||
|
"metrics": {
|
||||||
|
"memberIdentifier": "jroberts",
|
||||||
|
"memberName": "John Roberts",
|
||||||
|
"generatedAt": "2026-03-15T20:10:00.000Z",
|
||||||
|
"pipelineRevenue": 250000,
|
||||||
|
"closedWonRevenueMtd": 142500,
|
||||||
|
"closedWonRevenueYtd": 512000,
|
||||||
|
"winCount": { "mtd": 3, "ytd": 11 },
|
||||||
|
"lossCount": { "mtd": 1, "ytd": 4 },
|
||||||
|
"avgDaysToClose": 27.35,
|
||||||
|
"openOpportunityCount": 9,
|
||||||
|
"weightedPipelineRevenue": 173500,
|
||||||
|
"taxablePipelineRevenue": 198000,
|
||||||
|
"nonTaxablePipelineRevenue": 52000,
|
||||||
|
"avgOpenDealSize": 27777.78,
|
||||||
|
"avgWonDealSize": { "mtd": 47500, "ytd": 46545.45 },
|
||||||
|
"winRate": { "mtd": 0.75, "ytd": 0.7333 },
|
||||||
|
"lossRate": { "mtd": 0.25, "ytd": 0.2667 },
|
||||||
|
"assignedOpportunityCount": 14,
|
||||||
|
"cacheHitCount": 12,
|
||||||
|
"cacheMissCount": 2,
|
||||||
|
"cacheHitRate": 0.8571,
|
||||||
|
"opportunityBreakdown": {
|
||||||
|
"pipeline": [
|
||||||
|
{
|
||||||
|
"id": "clx1abc123",
|
||||||
|
"cwId": 98765,
|
||||||
|
"name": "Acme Corp - Network Upgrade",
|
||||||
|
"revenue": 45000,
|
||||||
|
"taxableRevenue": 45000,
|
||||||
|
"nonTaxableRevenue": 0,
|
||||||
|
"probability": 70,
|
||||||
|
"weightedRevenue": 31500,
|
||||||
|
"closedDate": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"closedWonMtd": [
|
||||||
|
{
|
||||||
|
"id": "clx1def456",
|
||||||
|
"cwId": 98720,
|
||||||
|
"name": "Globex - Security Stack",
|
||||||
|
"revenue": 142500,
|
||||||
|
"taxableRevenue": 120000,
|
||||||
|
"nonTaxableRevenue": 22500,
|
||||||
|
"probability": 100,
|
||||||
|
"weightedRevenue": 142500,
|
||||||
|
"closedDate": "2026-03-08T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"closedWonYtd": [ /* same shape, all won opps this year */ ],
|
||||||
|
"closedLostMtd": [ /* same shape, lost opps this month */ ],
|
||||||
|
"closedLostYtd": [ /* same shape, lost opps this year */ ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each entry in an `opportunityBreakdown` list contains:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `string` | Internal DB id (cuid) |
|
||||||
|
| `cwId` | `number` | ConnectWise opportunity ID |
|
||||||
|
| `name` | `string` | Opportunity name |
|
||||||
|
| `revenue` | `number` | Computed total revenue |
|
||||||
|
| `taxableRevenue` | `number` | Taxable portion of revenue |
|
||||||
|
| `nonTaxableRevenue` | `number` | Non-taxable portion of revenue |
|
||||||
|
| `probability` | `number` | Probability as a 0–100 percent value |
|
||||||
|
| `weightedRevenue` | `number` | `revenue × (probability / 100)` |
|
||||||
|
| `closedDate` | `string \| null` | ISO 8601 close date, or `null` if open |
|
||||||
|
|
||||||
|
**Response (`scope=all`):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": 200,
|
||||||
|
"message": "Sales opportunity metrics fetched successfully!",
|
||||||
|
"data": {
|
||||||
|
"generatedAt": "2026-03-15T20:10:00.000Z",
|
||||||
|
"activeMemberCount": 26,
|
||||||
|
"memberIdentifiers": ["jroberts", "asmith"],
|
||||||
|
"members": {
|
||||||
|
"jroberts": { "memberIdentifier": "jroberts" },
|
||||||
|
"asmith": { "memberIdentifier": "asmith" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"successful": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Get Opportunity
|
### Get Opportunity
|
||||||
|
|
||||||
**GET** `/sales/opportunities/:identifier`
|
**GET** `/sales/opportunities/opportunity/:identifier`
|
||||||
|
|
||||||
Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The response includes hydrated company data (with address and contacts) and full site details (with address) when available. CW data (activities, company, site) is served from the Redis cache when available; on cache miss, data is fetched live from CW and cached with an adaptive TTL.
|
Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The response includes hydrated company data (with address and contacts) and full site details (with address) when available. CW data (activities, company, site) is served from the Redis cache when available; on cache miss, data is fetched live from CW and cached with an adaptive TTL.
|
||||||
|
|
||||||
@@ -3183,6 +3377,8 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
|
|||||||
- `includeRegenData` _(optional)_ — When `"true"`, includes `quoteRegenData` on each quote object (applies to `?include=quotes`). Default: `false`.
|
- `includeRegenData` _(optional)_ — When `"true"`, includes `quoteRegenData` on each quote object (applies to `?include=quotes`). Default: `false`.
|
||||||
- `includeRegenParams` _(optional)_ — When `"true"`, includes `quoteRegenParams` on each quote object (applies to `?include=quotes`). Default: `false`.
|
- `includeRegenParams` _(optional)_ — When `"true"`, includes `quoteRegenParams` on each quote object (applies to `?include=quotes`). Default: `false`.
|
||||||
|
|
||||||
|
`expectedSalesTax` is computed from address using site address first and company address as fallback. Logic first checks state, then city-level jurisdiction when available in the sales tax data file, and otherwise falls back to the state's `avg_combined_rate`. Tennessee (`TN`) remains hard-coded to `0.0975` regardless of data-file values.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -3255,6 +3451,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
|
|||||||
},
|
},
|
||||||
"customerPO": null,
|
"customerPO": null,
|
||||||
"totalSalesTax": 0,
|
"totalSalesTax": 0,
|
||||||
|
"expectedSalesTax": 0.06,
|
||||||
"probability": 50,
|
"probability": 50,
|
||||||
"location": { "id": 1, "name": "Murray" },
|
"location": { "id": 1, "name": "Murray" },
|
||||||
"department": { "id": 5, "name": "Sales" },
|
"department": { "id": 5, "name": "Sales" },
|
||||||
@@ -3310,7 +3507,7 @@ Fetch a single opportunity by its internal ID or ConnectWise opportunity ID. The
|
|||||||
|
|
||||||
### Refresh Opportunity
|
### Refresh Opportunity
|
||||||
|
|
||||||
**POST** `/sales/opportunities/:identifier/refresh`
|
**POST** `/sales/opportunities/opportunity/:identifier/refresh`
|
||||||
|
|
||||||
Refresh an opportunity's local data by fetching the latest from ConnectWise. The response includes hydrated company data and site details.
|
Refresh an opportunity's local data by fetching the latest from ConnectWise. The response includes hydrated company data and site details.
|
||||||
|
|
||||||
@@ -3377,6 +3574,7 @@ Refresh an opportunity's local data by fetching the latest from ConnectWise. The
|
|||||||
"site": { "id": 50, "name": "Main Office" },
|
"site": { "id": 50, "name": "Main Office" },
|
||||||
"customerPO": null,
|
"customerPO": null,
|
||||||
"totalSalesTax": 0,
|
"totalSalesTax": 0,
|
||||||
|
"expectedSalesTax": 0.06,
|
||||||
"probability": 50,
|
"probability": 50,
|
||||||
"location": { "id": 1, "name": "Murray" },
|
"location": { "id": 1, "name": "Murray" },
|
||||||
"department": { "id": 5, "name": "Sales" },
|
"department": { "id": 5, "name": "Sales" },
|
||||||
@@ -3518,6 +3716,7 @@ Create a new opportunity in ConnectWise. The created opportunity is synced to th
|
|||||||
"site": { "id": 50, "name": "Main Office" },
|
"site": { "id": 50, "name": "Main Office" },
|
||||||
"customerPO": "PO-12345",
|
"customerPO": "PO-12345",
|
||||||
"totalSalesTax": 0,
|
"totalSalesTax": 0,
|
||||||
|
"expectedSalesTax": 0,
|
||||||
"probability": 0,
|
"probability": 0,
|
||||||
"location": { "id": 1, "name": "Murray" },
|
"location": { "id": 1, "name": "Murray" },
|
||||||
"department": null,
|
"department": null,
|
||||||
@@ -3552,7 +3751,7 @@ Create a new opportunity in ConnectWise. The created opportunity is synced to th
|
|||||||
|
|
||||||
### Update Opportunity
|
### Update Opportunity
|
||||||
|
|
||||||
**PATCH** `/sales/opportunities/:identifier`
|
**PATCH** `/sales/opportunities/opportunity/:identifier`
|
||||||
|
|
||||||
Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company, contact, site, description). Only the provided fields are patched; omitted fields remain unchanged. The updated opportunity is synced back to the local database and returned in the response.
|
Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company, contact, site, description). Only the provided fields are patched; omitted fields remain unchanged. The updated opportunity is synced back to the local database and returned in the response.
|
||||||
|
|
||||||
@@ -3643,6 +3842,7 @@ Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company,
|
|||||||
"site": { "id": 50, "name": "Main Office" },
|
"site": { "id": 50, "name": "Main Office" },
|
||||||
"customerPO": "PO-12345",
|
"customerPO": "PO-12345",
|
||||||
"totalSalesTax": 0,
|
"totalSalesTax": 0,
|
||||||
|
"expectedSalesTax": 0,
|
||||||
"probability": 50,
|
"probability": 50,
|
||||||
"location": { "id": 1, "name": "Murray" },
|
"location": { "id": 1, "name": "Murray" },
|
||||||
"department": { "id": 5, "name": "Sales" },
|
"department": { "id": 5, "name": "Sales" },
|
||||||
@@ -3667,7 +3867,7 @@ Update an opportunity's fields in ConnectWise (e.g. rating, sales rep, company,
|
|||||||
|
|
||||||
### Delete Opportunity
|
### Delete Opportunity
|
||||||
|
|
||||||
**DELETE** `/sales/opportunities/:identifier`
|
**DELETE** `/sales/opportunities/opportunity/:identifier`
|
||||||
|
|
||||||
Delete an opportunity from ConnectWise and the local database. All related Redis caches (activities, notes, contacts, products, CW data) are invalidated.
|
Delete an opportunity from ConnectWise and the local database. All related Redis caches (activities, notes, contacts, products, CW data) are invalidated.
|
||||||
|
|
||||||
@@ -3702,7 +3902,7 @@ Delete an opportunity from ConnectWise and the local database. All related Redis
|
|||||||
|
|
||||||
### Get Opportunity Products
|
### Get Opportunity Products
|
||||||
|
|
||||||
**GET** `/sales/opportunities/:identifier/products`
|
**GET** `/sales/opportunities/opportunity/:identifier/products`
|
||||||
|
|
||||||
Fetch products for an opportunity, scoped to items that have a matching ConnectWise procurement product (`forecastDetailId` link). Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Hot opportunities (updated within 3 days) have a 15-second TTL; others use a 30-minute lazy TTL. Products are returned sorted by the opportunity's local `productSequence` array when set; otherwise, items are sorted by their ConnectWise `sequenceNumber`.
|
Fetch products for an opportunity, scoped to items that have a matching ConnectWise procurement product (`forecastDetailId` link). Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Hot opportunities (updated within 3 days) have a 15-second TTL; others use a 30-minute lazy TTL. Products are returned sorted by the opportunity's local `productSequence` array when set; otherwise, items are sorted by their ConnectWise `sequenceNumber`.
|
||||||
|
|
||||||
@@ -3788,7 +3988,7 @@ Internal inventory data is sourced from the local CatalogItem database. If the p
|
|||||||
|
|
||||||
### Resequence Opportunity Products
|
### Resequence Opportunity Products
|
||||||
|
|
||||||
**PATCH** `/sales/opportunities/:identifier/products/sequence`
|
**PATCH** `/sales/opportunities/opportunity/:identifier/products/sequence`
|
||||||
|
|
||||||
Update the display order of products (forecast items) on an opportunity. The sequence is stored **locally** in the database (`productSequence` field on the Opportunity model) — no modifications are made to ConnectWise. This means forecast item IDs remain stable and procurement product linkages are unaffected.
|
Update the display order of products (forecast items) on an opportunity. The sequence is stored **locally** in the database (`productSequence` field on the Opportunity model) — no modifications are made to ConnectWise. This means forecast item IDs remain stable and procurement product linkages are unaffected.
|
||||||
|
|
||||||
@@ -3850,7 +4050,7 @@ When a `productSequence` is set, `GET .../products` returns items in that order.
|
|||||||
|
|
||||||
### Edit Opportunity Product
|
### Edit Opportunity Product
|
||||||
|
|
||||||
**PATCH** `/sales/opportunities/:identifier/products/:productId/edit`
|
**PATCH** `/sales/opportunities/opportunity/:identifier/products/:productId/edit`
|
||||||
|
|
||||||
Edit a product line item on an opportunity. This route supports forecast-backed fields and procurement-backed fields (including custom fields for narrative/notes).
|
Edit a product line item on an opportunity. This route supports forecast-backed fields and procurement-backed fields (including custom fields for narrative/notes).
|
||||||
|
|
||||||
@@ -3875,12 +4075,13 @@ At least one field is required.
|
|||||||
"unitCost": 62.5,
|
"unitCost": 62.5,
|
||||||
"customerDescription": "Onsite labor for rack install",
|
"customerDescription": "Onsite labor for rack install",
|
||||||
"productNarrative": "Install, cable, and validate cutover",
|
"productNarrative": "Install, cable, and validate cutover",
|
||||||
"procurementNotes": "Coordinate site contact before arrival"
|
"procurementNotes": "Coordinate site contact before arrival",
|
||||||
|
"taxableFlag": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| --------------------- | ------ | ---------------------------------------------------------- |
|
| --------------------- | ------- | ---------------------------------------------------------- |
|
||||||
| `productDescription` | string | Product description |
|
| `productDescription` | string | Product description |
|
||||||
| `quantity` | number | Quantity |
|
| `quantity` | number | Quantity |
|
||||||
| `unitPrice` | number | Unit price (maps to procurement `price`, forecast revenue) |
|
| `unitPrice` | number | Unit price (maps to procurement `price`, forecast revenue) |
|
||||||
@@ -3888,6 +4089,7 @@ At least one field is required.
|
|||||||
| `customerDescription` | string | Customer-facing description |
|
| `customerDescription` | string | Customer-facing description |
|
||||||
| `productNarrative` | string | Custom field `Product Narrative` (`id: 46`) |
|
| `productNarrative` | string | Custom field `Product Narrative` (`id: 46`) |
|
||||||
| `procurementNotes` | string | Custom field `Procurement Notes` (`id: 29`) |
|
| `procurementNotes` | string | Custom field `Procurement Notes` (`id: 29`) |
|
||||||
|
| `taxableFlag` | boolean | Whether this item is taxable (forecast field) |
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
@@ -3913,7 +4115,7 @@ At least one field is required.
|
|||||||
|
|
||||||
### Cancel / Uncancel Opportunity Product
|
### Cancel / Uncancel Opportunity Product
|
||||||
|
|
||||||
**PATCH** `/sales/opportunities/:identifier/products/:productId/cancel`
|
**PATCH** `/sales/opportunities/opportunity/:identifier/products/:productId/cancel`
|
||||||
|
|
||||||
Set cancellation state for a product line item using procurement cancellation fields.
|
Set cancellation state for a product line item using procurement cancellation fields.
|
||||||
|
|
||||||
@@ -3972,7 +4174,7 @@ Set cancellation state for a product line item using procurement cancellation fi
|
|||||||
|
|
||||||
### Delete Product from Opportunity
|
### Delete Product from Opportunity
|
||||||
|
|
||||||
**DELETE** `/sales/opportunities/:identifier/products/:productId`
|
**DELETE** `/sales/opportunities/opportunity/:identifier/products/:productId`
|
||||||
|
|
||||||
Remove a forecast item (product) from an opportunity in ConnectWise. The item is also removed from the local `productSequence` array and the products cache is invalidated.
|
Remove a forecast item (product) from an opportunity in ConnectWise. The item is also removed from the local `productSequence` array and the products cache is invalidated.
|
||||||
|
|
||||||
@@ -4009,7 +4211,7 @@ Remove a forecast item (product) from an opportunity in ConnectWise. The item is
|
|||||||
|
|
||||||
### Add Product to Opportunity
|
### Add Product to Opportunity
|
||||||
|
|
||||||
**POST** `/sales/opportunities/:identifier/products`
|
**POST** `/sales/opportunities/opportunity/:identifier/products`
|
||||||
|
|
||||||
Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against `sales.opportunity.product.field.<field>` permissions — only fields the user has permission for are forwarded to ConnectWise.
|
Add a new product (forecast item) to an opportunity in ConnectWise. The request body is validated with Zod, then each submitted field is gated against `sales.opportunity.product.field.<field>` permissions — only fields the user has permission for are forwarded to ConnectWise.
|
||||||
|
|
||||||
@@ -4119,7 +4321,7 @@ All fields are optional. Only fields the user has the corresponding `sales.oppor
|
|||||||
|
|
||||||
### Add SPECIAL ORDER Product
|
### Add SPECIAL ORDER Product
|
||||||
|
|
||||||
**POST** `/sales/opportunities/:identifier/products/special-order`
|
**POST** `/sales/opportunities/opportunity/:identifier/products/special-order`
|
||||||
|
|
||||||
Add one or more products as **SPECIAL ORDER** procurement line items. This route creates ConnectWise procurement products (not forecast items) and enforces stable defaults for quick-entry workflows.
|
Add one or more products as **SPECIAL ORDER** procurement line items. This route creates ConnectWise procurement products (not forecast items) and enforces stable defaults for quick-entry workflows.
|
||||||
|
|
||||||
@@ -4199,7 +4401,7 @@ Accepts either a single object or an array of objects.
|
|||||||
|
|
||||||
### Get Labor Product Options
|
### Get Labor Product Options
|
||||||
|
|
||||||
**GET** `/sales/opportunities/:identifier/products/labor/options`
|
**GET** `/sales/opportunities/opportunity/:identifier/products/labor/options`
|
||||||
|
|
||||||
Fetch the resolved **Field** and **Tech** labor catalog products plus default labor pricing metadata so the UI can hydrate the labor-entry form without hardcoding catalog IDs.
|
Fetch the resolved **Field** and **Tech** labor catalog products plus default labor pricing metadata so the UI can hydrate the labor-entry form without hardcoding catalog IDs.
|
||||||
|
|
||||||
@@ -4250,7 +4452,7 @@ Fetch the resolved **Field** and **Tech** labor catalog products plus default la
|
|||||||
|
|
||||||
### Add Labor Product
|
### Add Labor Product
|
||||||
|
|
||||||
**POST** `/sales/opportunities/:identifier/products/labor`
|
**POST** `/sales/opportunities/opportunity/:identifier/products/labor`
|
||||||
|
|
||||||
Add a labor line item to an opportunity using one of the two canonical labor catalog products (**Field** or **Tech**). The route resolves both labor products from the local catalog, then picks the selected style and creates a ConnectWise procurement product.
|
Add a labor line item to an opportunity using one of the two canonical labor catalog products (**Field** or **Tech**). The route resolves both labor products from the local catalog, then picks the selected style and creates a ConnectWise procurement product.
|
||||||
|
|
||||||
@@ -4340,7 +4542,7 @@ Add a labor line item to an opportunity using one of the two canonical labor cat
|
|||||||
|
|
||||||
### Fetch Committed Quotes
|
### Fetch Committed Quotes
|
||||||
|
|
||||||
**GET** `/sales/opportunities/:identifier/quotes`
|
**GET** `/sales/opportunities/opportunity/:identifier/quotes`
|
||||||
|
|
||||||
Fetch all committed (finalized) quotes for an opportunity, ordered by most recent first.
|
Fetch all committed (finalized) quotes for an opportunity, ordered by most recent first.
|
||||||
|
|
||||||
@@ -4384,7 +4586,7 @@ Fetch all committed (finalized) quotes for an opportunity, ordered by most recen
|
|||||||
|
|
||||||
### Commit Quote
|
### Commit Quote
|
||||||
|
|
||||||
**POST** `/sales/opportunities/:identifier/quote/commit`
|
**POST** `/sales/opportunities/opportunity/:identifier/quote/commit`
|
||||||
|
|
||||||
Generate a finalized (non-preview) quote PDF for an opportunity and store it in the database with regeneration metadata and creator attribution.
|
Generate a finalized (non-preview) quote PDF for an opportunity and store it in the database with regeneration metadata and creator attribution.
|
||||||
|
|
||||||
@@ -4501,7 +4703,7 @@ Generate a finalized (non-preview) quote PDF for an opportunity and store it in
|
|||||||
|
|
||||||
### Preview Quote
|
### Preview Quote
|
||||||
|
|
||||||
**GET** `/sales/opportunities/:identifier/quote/:quoteId/preview`
|
**GET** `/sales/opportunities/opportunity/:identifier/quote/:quoteId/preview`
|
||||||
|
|
||||||
Regenerate a preview-stamped version of an existing committed quote PDF using its stored generation parameters. The PDF is not persisted — it is returned as a base64-encoded string.
|
Regenerate a preview-stamped version of an existing committed quote PDF using its stored generation parameters. The PDF is not persisted — it is returned as a base64-encoded string.
|
||||||
|
|
||||||
@@ -4532,7 +4734,7 @@ Regenerate a preview-stamped version of an existing committed quote PDF using it
|
|||||||
|
|
||||||
### Download Quote
|
### Download Quote
|
||||||
|
|
||||||
**GET** `/sales/opportunities/:identifier/quote/:quoteId/download`
|
**GET** `/sales/opportunities/opportunity/:identifier/quote/:quoteId/download`
|
||||||
|
|
||||||
Download a committed quote PDF by its ID. Returns the PDF file as a base64-encoded string. Each call automatically records a download entry with the timestamp, user info, and fetch action in the quote's `downloads` array. Download-time metadata (downloadedAt, downloadedBy) is also injected into the PDF's document properties.
|
Download a committed quote PDF by its ID. Returns the PDF file as a base64-encoded string. Each call automatically records a download entry with the timestamp, user info, and fetch action in the quote's `downloads` array. Download-time metadata (downloadedAt, downloadedBy) is also injected into the PDF's document properties.
|
||||||
|
|
||||||
@@ -4586,7 +4788,7 @@ Download a committed quote PDF by its ID. Returns the PDF file as a base64-encod
|
|||||||
|
|
||||||
### Fetch Quote Download History
|
### Fetch Quote Download History
|
||||||
|
|
||||||
**GET** `/sales/opportunities/:identifier/quotes/downloads`
|
**GET** `/sales/opportunities/opportunity/:identifier/quotes/downloads`
|
||||||
|
|
||||||
Fetch download/print history for all committed quotes on an opportunity. Returns an array of quote summaries, each containing the full `downloads` array with every download/print record. This is an admin-level route intended for audit and tracking purposes.
|
Fetch download/print history for all committed quotes on an opportunity. Returns an array of quote summaries, each containing the full `downloads` array with every download/print record. This is an admin-level route intended for audit and tracking purposes.
|
||||||
|
|
||||||
@@ -4636,7 +4838,7 @@ Fetch download/print history for all committed quotes on an opportunity. Returns
|
|||||||
|
|
||||||
### Get Opportunity Notes
|
### Get Opportunity Notes
|
||||||
|
|
||||||
**GET** `/sales/opportunities/:identifier/notes`
|
**GET** `/sales/opportunities/opportunity/:identifier/notes`
|
||||||
|
|
||||||
Fetch notes for an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when notes are created, updated, or deleted.
|
Fetch notes for an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when notes are created, updated, or deleted.
|
||||||
|
|
||||||
@@ -4676,7 +4878,7 @@ Fetch notes for an opportunity. Data is served from the Redis cache when availab
|
|||||||
|
|
||||||
### Get Single Opportunity Note
|
### Get Single Opportunity Note
|
||||||
|
|
||||||
**GET** `/sales/opportunities/:identifier/notes/:noteId`
|
**GET** `/sales/opportunities/opportunity/:identifier/notes/:noteId`
|
||||||
|
|
||||||
Fetch a single note by its ConnectWise note ID for an opportunity.
|
Fetch a single note by its ConnectWise note ID for an opportunity.
|
||||||
|
|
||||||
@@ -4715,7 +4917,7 @@ Fetch a single note by its ConnectWise note ID for an opportunity.
|
|||||||
|
|
||||||
### Create Opportunity Note
|
### Create Opportunity Note
|
||||||
|
|
||||||
**POST** `/sales/opportunities/:identifier/notes`
|
**POST** `/sales/opportunities/opportunity/:identifier/notes`
|
||||||
|
|
||||||
Create a new note on an opportunity in ConnectWise.
|
Create a new note on an opportunity in ConnectWise.
|
||||||
|
|
||||||
@@ -4767,7 +4969,7 @@ Create a new note on an opportunity in ConnectWise.
|
|||||||
|
|
||||||
### Update Opportunity Note
|
### Update Opportunity Note
|
||||||
|
|
||||||
**PATCH** `/sales/opportunities/:identifier/notes/:noteId`
|
**PATCH** `/sales/opportunities/opportunity/:identifier/notes/:noteId`
|
||||||
|
|
||||||
Update an existing note on an opportunity in ConnectWise.
|
Update an existing note on an opportunity in ConnectWise.
|
||||||
|
|
||||||
@@ -4822,7 +5024,7 @@ Update an existing note on an opportunity in ConnectWise.
|
|||||||
|
|
||||||
### Delete Opportunity Note
|
### Delete Opportunity Note
|
||||||
|
|
||||||
**DELETE** `/sales/opportunities/:identifier/notes/:noteId`
|
**DELETE** `/sales/opportunities/opportunity/:identifier/notes/:noteId`
|
||||||
|
|
||||||
Delete a note from an opportunity in ConnectWise.
|
Delete a note from an opportunity in ConnectWise.
|
||||||
|
|
||||||
@@ -4849,7 +5051,7 @@ Delete a note from an opportunity in ConnectWise.
|
|||||||
|
|
||||||
### Get Opportunity Contacts
|
### Get Opportunity Contacts
|
||||||
|
|
||||||
**GET** `/sales/opportunities/:identifier/contacts`
|
**GET** `/sales/opportunities/opportunity/:identifier/contacts`
|
||||||
|
|
||||||
Fetch contacts associated with an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when contacts are created, updated, or deleted.
|
Fetch contacts associated with an opportunity. Data is served from the Redis cache when available; on cache miss, data is fetched live from ConnectWise and cached. Cache is invalidated automatically when contacts are created, updated, or deleted.
|
||||||
|
|
||||||
@@ -4903,7 +5105,8 @@ The opportunity workflow system is an internal engine that manages the lifecycle
|
|||||||
| QuoteSent | 43 | 03. Quote Sent | No | Quote has been sent to the customer |
|
| QuoteSent | 43 | 03. Quote Sent | No | Quote has been sent to the customer |
|
||||||
| ConfirmedQuote | 57 | 04. Confirmed Quote | No | Customer acknowledged receipt |
|
| ConfirmedQuote | 57 | 04. Confirmed Quote | No | Customer acknowledged receipt |
|
||||||
| Active | 58 | 05. Active | No | Quote in revision/flux |
|
| Active | 58 | 05. Active | No | Quote in revision/flux |
|
||||||
| PendingSent | 60 | Pending Sent | No | Review approved, awaiting rep to send |
|
| ReadyToSend | 63 | Ready to Send | No | Review approved; ready for rep send |
|
||||||
|
| PendingSent | 60 | Pending Sent | No | Deprecated legacy status; existing records may still use it |
|
||||||
| PendingRevision | 61 | Pending Revision | No | Review rejected, awaiting rep revision |
|
| PendingRevision | 61 | Pending Revision | No | Review rejected, awaiting rep revision |
|
||||||
| PendingWon | 49 | 91. Pending Won | No | Won pending finalization by authorized user |
|
| PendingWon | 49 | 91. Pending Won | No | Won pending finalization by authorized user |
|
||||||
| Won | 29 | 95. Won | Yes | Final positive outcome — immutable |
|
| Won | 29 | 95. Won | Yes | Final positive outcome — immutable |
|
||||||
@@ -4917,8 +5120,9 @@ The opportunity workflow system is an internal engine that manages the lifecycle
|
|||||||
| --------------- | ------------------------------------------------------------------------------- |
|
| --------------- | ------------------------------------------------------------------------------- |
|
||||||
| PendingNew | New |
|
| PendingNew | New |
|
||||||
| New | InternalReview, QuoteSent, Canceled |
|
| New | InternalReview, QuoteSent, Canceled |
|
||||||
| InternalReview | PendingSent (approve), PendingRevision (reject), QuoteSent (send), Canceled |
|
| InternalReview | ReadyToSend (approve), PendingRevision (reject), QuoteSent (send), Canceled |
|
||||||
| PendingSent | QuoteSent |
|
| ReadyToSend | QuoteSent |
|
||||||
|
| PendingSent | QuoteSent _(deprecated legacy path for existing records only)_ |
|
||||||
| PendingRevision | Active |
|
| PendingRevision | Active |
|
||||||
| QuoteSent | ConfirmedQuote, Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
|
| QuoteSent | ConfirmedQuote, Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
|
||||||
| ConfirmedQuote | Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
|
| ConfirmedQuote | Won/PendingWon, PendingLost, Active, InternalReview (cold auto) |
|
||||||
@@ -4981,11 +5185,11 @@ Triggered via `triggerColdDetection()` (intended for schedulers/automation, not
|
|||||||
|
|
||||||
### Workflow API Routes
|
### Workflow API Routes
|
||||||
|
|
||||||
These routes expose the opportunity workflow to the UI. They live under `/v1/sales/opportunities/:identifier/workflow`.
|
These routes expose the opportunity workflow to the UI. They live under `/v1/sales/opportunities/opportunity/:identifier/workflow`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### `GET /v1/sales/opportunities/:identifier/workflow`
|
#### `GET /v1/sales/opportunities/opportunity/:identifier/workflow`
|
||||||
|
|
||||||
Fetch the current workflow state for an opportunity — current status, stage, available actions, cold-detection result, and whether each action is permitted for the authenticated user.
|
Fetch the current workflow state for an opportunity — current status, stage, available actions, cold-detection result, and whether each action is permitted for the authenticated user.
|
||||||
|
|
||||||
@@ -5036,7 +5240,7 @@ Fetch the current workflow state for an opportunity — current status, stage, a
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### `POST /v1/sales/opportunities/:identifier/workflow`
|
#### `POST /v1/sales/opportunities/opportunity/:identifier/workflow`
|
||||||
|
|
||||||
Execute a workflow action. Accepts a discriminated union of `{ action, payload }` and routes it through the workflow engine. The body is validated with Zod.
|
Execute a workflow action. Accepts a discriminated union of `{ action, payload }` and routes it through the workflow engine. The body is validated with Zod.
|
||||||
|
|
||||||
@@ -5071,8 +5275,8 @@ Execute a workflow action. Accepts a discriminated union of `{ action, payload }
|
|||||||
| ---------------- | --------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------- |
|
| ---------------- | --------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
| `acceptNew` | — | `note`, `timeSpent` | PendingNew → New |
|
| `acceptNew` | — | `note`, `timeSpent` | PendingNew → New |
|
||||||
| `requestReview` | `note` | `timeSpent` | New/Active → InternalReview |
|
| `requestReview` | `note` | `timeSpent` | New/Active → InternalReview |
|
||||||
| `reviewDecision` | `note`, `decision` | `timeSpent` | InternalReview → PendingSent/PendingRevision/QuoteSent/Canceled |
|
| `reviewDecision` | `note`, `decision` | `timeSpent` | InternalReview → ReadyToSend/PendingRevision/QuoteSent/Canceled |
|
||||||
| `sendQuote` | — | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | PendingSent/New → QuoteSent (+ compound transitions) |
|
| `sendQuote` | — | `note`, `timeSpent`, `quoteConfirmed`, `won`, `lost`, `needsRevision` | ReadyToSend/New → QuoteSent (+ compound transitions) |
|
||||||
| `confirmQuote` | — | `note`, `timeSpent` | QuoteSent → ConfirmedQuote |
|
| `confirmQuote` | — | `note`, `timeSpent` | QuoteSent → ConfirmedQuote |
|
||||||
| `finalize` | `note`, `outcome` (`"won"` or `"lost"`) | `timeSpent` | Pending → Won/Lost (or direct if permitted) |
|
| `finalize` | `note`, `outcome` (`"won"` or `"lost"`) | `timeSpent` | Pending → Won/Lost (or direct if permitted) |
|
||||||
| `resurrect` | `note` | `timeSpent` | PendingLost → Active |
|
| `resurrect` | `note` | `timeSpent` | PendingLost → Active |
|
||||||
@@ -5092,8 +5296,8 @@ Execute a workflow action. Accepts a discriminated union of `{ action, payload }
|
|||||||
"message": "Workflow action completed successfully.",
|
"message": "Workflow action completed successfully.",
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"data": {
|
"data": {
|
||||||
"previousStatusId": 60,
|
"previousStatusId": 63,
|
||||||
"previousStatus": "PendingSent",
|
"previousStatus": "ReadyToSend",
|
||||||
"newStatusId": 43,
|
"newStatusId": 43,
|
||||||
"newStatus": "QuoteSent",
|
"newStatus": "QuoteSent",
|
||||||
"activitiesCreated": [ { "...activity JSON..." } ],
|
"activitiesCreated": [ { "...activity JSON..." } ],
|
||||||
@@ -5120,7 +5324,7 @@ Execute a workflow action. Accepts a discriminated union of `{ action, payload }
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### `GET /v1/sales/opportunities/:identifier/workflow/history`
|
#### `GET /v1/sales/opportunities/opportunity/:identifier/workflow/history`
|
||||||
|
|
||||||
Fetch the workflow activity history for an opportunity. Returns only CW activities that have a valid `Optima_Type` custom field set, sorted newest first.
|
Fetch the workflow activity history for an opportunity. Returns only CW activities that have a valid `Optima_Type` custom field set, sorted newest first.
|
||||||
|
|
||||||
|
|||||||
+36
@@ -8,6 +8,8 @@ This document describes the caching layer used in the Optima API, covering the R
|
|||||||
|
|
||||||
The API caches expensive ConnectWise (CW) API responses in **Redis** to reduce latency and avoid CW rate limits. The primary cache layer is the **opportunity cache** (`src/modules/cache/opportunityCache.ts`), which proactively warms data for all non-closed opportunities on a background interval.
|
The API caches expensive ConnectWise (CW) API responses in **Redis** to reduce latency and avoid CW rate limits. The primary cache layer is the **opportunity cache** (`src/modules/cache/opportunityCache.ts`), which proactively warms data for all non-closed opportunities on a background interval.
|
||||||
|
|
||||||
|
The API also maintains a Redis-backed **sales member metrics cache** (`src/modules/cache/salesOpportunityMetricsCache.ts`) refreshed every 5 minutes. It precomputes per-member dashboard/reporting figures (pipeline revenue, won/lost counts, win rate, avg days to close, and related metrics) for fast reads from `/v1/sales/opportunities/metrics`.
|
||||||
|
|
||||||
### Key design principles
|
### Key design principles
|
||||||
|
|
||||||
- **Adaptive TTLs** — cache durations are computed dynamically based on how "hot" an opportunity is (recently updated = shorter TTL = fresher data).
|
- **Adaptive TTLs** — cache durations are computed dynamically based on how "hot" an opportunity is (recently updated = shorter TTL = fresher data).
|
||||||
@@ -38,6 +40,14 @@ Inventory-adjustment-driven catalog sync adds a targeted product cache:
|
|||||||
| ------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
| ------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `catalog:item:cw:{cwId}` | Full CW catalog item + computed `onHand` + DB row snapshot | `GET /procurement/adjustments` + `GET /procurement/catalog/:id` + catalog inventory endpoint |
|
| `catalog:item:cw:{cwId}` | Full CW catalog item + computed `onHand` + DB row snapshot | `GET /procurement/adjustments` + `GET /procurement/catalog/:id` + catalog inventory endpoint |
|
||||||
|
|
||||||
|
Sales opportunity metrics caching adds member-focused keys:
|
||||||
|
|
||||||
|
| Cache Key Pattern | Data | Source |
|
||||||
|
| ------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||||
|
| `sales:metrics:members:all` | Envelope of all active-member metrics | Precomputed from active CW members + assigned opportunities + products cache/CW fetch |
|
||||||
|
| `sales:metrics:member:{cwIdentifier}` | One member's computed metrics snapshot | Same as above |
|
||||||
|
| `sales:metrics:oppRevenue:{cwOppId}` | Per-opportunity computed revenue blob | Metrics refresh lookups (products cache-first, then manager/controller fallback) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## TTL Algorithms
|
## TTL Algorithms
|
||||||
@@ -172,6 +182,31 @@ The thunk pattern is critical. Previously, tasks were pushed as already-executin
|
|||||||
|
|
||||||
At these settings, a full sweep of ~500 expired keys completes in ~1-2 minutes with zero CW errors and ~230ms median latency.
|
At these settings, a full sweep of ~500 expired keys completes in ~1-2 minutes with zero CW errors and ~230ms median latency.
|
||||||
|
|
||||||
|
### Sales metrics refresh job
|
||||||
|
|
||||||
|
**Function:** `refreshSalesOpportunityMetricsCache()` in `src/modules/cache/salesOpportunityMetricsCache.ts`
|
||||||
|
|
||||||
|
**Interval:** Every 5 minutes, triggered from `src/index.ts`.
|
||||||
|
|
||||||
|
**Startup behavior:** On app startup, the refresh is invoked once with `forceColdLoad=true`, which clears metrics-owned Redis keys and bypasses metrics/product cache reuse for that initial rebuild. Subsequent interval runs use the normal warm path.
|
||||||
|
|
||||||
|
Refresh flow:
|
||||||
|
|
||||||
|
1. Fetch all active CW members (`inactiveFlag=false`).
|
||||||
|
Source: local `CwMember` table (kept in sync by the existing members refresh job).
|
||||||
|
2. Query DB opportunities assigned to those members (primary or secondary rep), scoped to open opportunities plus YTD-closed opportunities.
|
||||||
|
3. For each opportunity, compute revenue cache-first from `sales:metrics:oppRevenue:{cwOppId}` then `opp:products:{cwOpportunityId}`, and fallback through the manager/controller path (`opportunities.fetchRecord(...).fetchProducts()`) on miss.
|
||||||
|
4. Aggregate member metrics (pipeline revenue, won/lost MTD+YTD counts, avg days to close, weighted pipeline, win/loss rates, and related KPIs).
|
||||||
|
5. Write per-opportunity revenue blobs plus all-member and per-member snapshots to Redis with a 10-minute TTL.
|
||||||
|
|
||||||
|
Safety controls:
|
||||||
|
|
||||||
|
- **Single-flight lock** prevents overlapping refresh runs if a prior run is still in progress.
|
||||||
|
- **Per-opportunity timeout guard** ensures slow CW product lookups degrade to zero-revenue fallback instead of stalling the full refresh.
|
||||||
|
- **Force-cold-load mode** clears `sales:metrics:*` runtime state owned by the metrics cache before rebuilding startup data.
|
||||||
|
|
||||||
|
This cache-first model prioritizes metrics-owned opportunity revenue keys first, then opportunity product cache entries, and only reaches CW when needed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Retry Logic (`withCwRetry`)
|
## Retry Logic (`withCwRetry`)
|
||||||
@@ -343,6 +378,7 @@ src/index.ts
|
|||||||
| `src/modules/cw-utils/cwApiLogger.ts` | Axios interceptor for JSONL call logging |
|
| `src/modules/cw-utils/cwApiLogger.ts` | Axios interceptor for JSONL call logging |
|
||||||
| `src/modules/cw-utils/fetchCompany.ts` | Company fetch with retry |
|
| `src/modules/cw-utils/fetchCompany.ts` | Company fetch with retry |
|
||||||
| `src/modules/cw-utils/procurement/listenInventoryAdjustments.ts` | Adjustment listener for targeted catalog-item cache + DB sync |
|
| `src/modules/cw-utils/procurement/listenInventoryAdjustments.ts` | Adjustment listener for targeted catalog-item cache + DB sync |
|
||||||
|
| `src/modules/cache/salesOpportunityMetricsCache.ts` | 5-minute active-member opportunity metrics cache |
|
||||||
| `src/constants.ts` | CW Axios instance config (timeout, logger) |
|
| `src/constants.ts` | CW Axios instance config (timeout, logger) |
|
||||||
| `src/index.ts` | Refresh interval registration |
|
| `src/index.ts` | Refresh interval registration |
|
||||||
| `debug-scripts/analyze-cw-calls.py` | CW API call analysis script |
|
| `debug-scripts/analyze-cw-calls.py` | CW API call analysis script |
|
||||||
|
|||||||
+6
-1
@@ -143,9 +143,13 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy
|
|||||||
**WebSocket note:** The `/secure` socket event chain `opp:live_quote_preview` and `opp:live_quote_preview:<id>:data` is gated by `sales.opportunity.fetch`.
|
**WebSocket note:** The `/secure` socket event chain `opp:live_quote_preview` and `opp:live_quote_preview:<id>:data` is gated by `sales.opportunity.fetch`.
|
||||||
|
|
||||||
| Permission Node | Description | Used In | Dependencies |
|
| Permission Node | Description | Used In | Dependencies |
|
||||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
|
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------ |
|
||||||
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/opportunities/[id]/fetch.ts](src/api/sales/opportunities/[id]/fetch.ts), [src/api/sales/opportunities/[id]/products/fetchAll.ts](src/api/sales/opportunities/[id]/products/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetchAll.ts](src/api/sales/opportunities/[id]/notes/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetch.ts](src/api/sales/opportunities/[id]/notes/fetch.ts), [src/api/sales/opportunities/[id]/contacts/fetchAll.ts](src/api/sales/opportunities/[id]/contacts/fetchAll.ts), [src/api/sockets/events/liveQuotePreview.ts](src/api/sockets/events/liveQuotePreview.ts) | |
|
| `sales.opportunity.fetch` | Fetch a single opportunity and its CW sub-resources (products, notes, contacts) | [src/api/sales/opportunities/[id]/fetch.ts](src/api/sales/opportunities/[id]/fetch.ts), [src/api/sales/opportunities/[id]/products/fetchAll.ts](src/api/sales/opportunities/[id]/products/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetchAll.ts](src/api/sales/opportunities/[id]/notes/fetchAll.ts), [src/api/sales/opportunities/[id]/notes/fetch.ts](src/api/sales/opportunities/[id]/notes/fetch.ts), [src/api/sales/opportunities/[id]/contacts/fetchAll.ts](src/api/sales/opportunities/[id]/contacts/fetchAll.ts), [src/api/sockets/events/liveQuotePreview.ts](src/api/sockets/events/liveQuotePreview.ts) | |
|
||||||
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/opportunities/fetchAll.ts](src/api/sales/opportunities/fetchAll.ts), [src/api/sales/opportunities/count.ts](src/api/sales/opportunities/count.ts), [src/api/sales/opportunities/fetchTypes.ts](src/api/sales/opportunities/fetchTypes.ts) | |
|
| `sales.opportunity.fetch.many` | Fetch multiple opportunities (paginated/searchable), count, or opportunity types | [src/api/sales/opportunities/fetchAll.ts](src/api/sales/opportunities/fetchAll.ts), [src/api/sales/opportunities/count.ts](src/api/sales/opportunities/count.ts), [src/api/sales/opportunities/fetchTypes.ts](src/api/sales/opportunities/fetchTypes.ts) | |
|
||||||
|
| `sales.opportunity.fetch.@me` | View the personal sales dashboard showing opportunities assigned to the current user | UI-only (client-side gate) | |
|
||||||
|
| `sales.opportunity.fetch.all` | View all opportunities across all users (All Opportunities tab and View All button in the sales dashboard) | UI-only (client-side gate) | `sales.opportunity.fetch.many` |
|
||||||
|
| `sales.opportunity.metrics.all` | Allow `scope=all` on sales opportunity metrics endpoint to read cached metrics for all active members | [src/api/sales/opportunities/metrics.ts](src/api/sales/opportunities/metrics.ts) | `sales.opportunity.fetch.many` |
|
||||||
|
| `sales.opportunity.metrics.identifier.override` | Allow `identifier=<cwIdentifier>` override on sales opportunity metrics endpoint for querying another member | [src/api/sales/opportunities/metrics.ts](src/api/sales/opportunities/metrics.ts) | `sales.opportunity.fetch.many` |
|
||||||
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/opportunities/[id]/refresh.ts](src/api/sales/opportunities/[id]/refresh.ts) | `sales.opportunity.fetch` |
|
| `sales.opportunity.refresh` | Refresh a single opportunity's local data from ConnectWise | [src/api/sales/opportunities/[id]/refresh.ts](src/api/sales/opportunities/[id]/refresh.ts) | `sales.opportunity.fetch` |
|
||||||
| `sales.opportunity.update` | Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise | [src/api/sales/opportunities/[id]/update.ts](src/api/sales/opportunities/[id]/update.ts) | `sales.opportunity.fetch` |
|
| `sales.opportunity.update` | Update an opportunity's fields (rating, sales rep, company, contact, site, description, etc.) in ConnectWise | [src/api/sales/opportunities/[id]/update.ts](src/api/sales/opportunities/[id]/update.ts) | `sales.opportunity.fetch` |
|
||||||
| `sales.opportunity.create` | Create a new opportunity in ConnectWise | [src/api/sales/opportunities/create.ts](src/api/sales/opportunities/create.ts) | |
|
| `sales.opportunity.create` | Create a new opportunity in ConnectWise | [src/api/sales/opportunities/create.ts](src/api/sales/opportunities/create.ts) | |
|
||||||
@@ -174,6 +178,7 @@ Permissions for accessing and managing sales opportunities. Opportunities are sy
|
|||||||
| `sales.opportunity.reopen` | Re-open a cancelled opportunity. Required to transition an opportunity from Canceled back to Active. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
|
| `sales.opportunity.reopen` | Re-open a cancelled opportunity. Required to transition an opportunity from Canceled back to Active. | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
|
||||||
| `sales.opportunity.win` | Mark an opportunity as won (or pending won). Gates the win button in the UI. Required for finalize(won), sendQuote(won), and transitionToPending(won). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
|
| `sales.opportunity.win` | Mark an opportunity as won (or pending won). Gates the win button in the UI. Required for finalize(won), sendQuote(won), and transitionToPending(won). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
|
||||||
| `sales.opportunity.lose` | Mark an opportunity as lost (or pending lost). Gates the lose button in the UI. Required for finalize(lost), sendQuote(lost), and transitionToPending(lost). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
|
| `sales.opportunity.lose` | Mark an opportunity as lost (or pending lost). Gates the lose button in the UI. Required for finalize(lost), sendQuote(lost), and transitionToPending(lost). | [src/workflows/wf.opportunity.ts](src/workflows/wf.opportunity.ts) | `sales.opportunity.workflow` |
|
||||||
|
| `sales.isRepresentative` | Designates the user as a sales representative; used for reporting and filtering purposes. | _(not yet used in routes)_ | |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
|
<summary><strong>Field-level permissions for <code>sales.opportunity.product.add</code></strong></summary>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1487,6 +1487,7 @@ export const OpportunityScalarFieldEnum = {
|
|||||||
companyId: 'companyId',
|
companyId: 'companyId',
|
||||||
productSequence: 'productSequence',
|
productSequence: 'productSequence',
|
||||||
cwLastUpdated: 'cwLastUpdated',
|
cwLastUpdated: 'cwLastUpdated',
|
||||||
|
cwDateEntered: 'cwDateEntered',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ export const OpportunityScalarFieldEnum = {
|
|||||||
companyId: 'companyId',
|
companyId: 'companyId',
|
||||||
productSequence: 'productSequence',
|
productSequence: 'productSequence',
|
||||||
cwLastUpdated: 'cwLastUpdated',
|
cwLastUpdated: 'cwLastUpdated',
|
||||||
|
cwDateEntered: 'cwDateEntered',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export type OpportunityMinAggregateOutputType = {
|
|||||||
closedByCwId: number | null
|
closedByCwId: number | null
|
||||||
companyId: string | null
|
companyId: string | null
|
||||||
cwLastUpdated: Date | null
|
cwLastUpdated: Date | null
|
||||||
|
cwDateEntered: Date | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
}
|
}
|
||||||
@@ -164,6 +165,7 @@ export type OpportunityMaxAggregateOutputType = {
|
|||||||
closedByCwId: number | null
|
closedByCwId: number | null
|
||||||
companyId: string | null
|
companyId: string | null
|
||||||
cwLastUpdated: Date | null
|
cwLastUpdated: Date | null
|
||||||
|
cwDateEntered: Date | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
}
|
}
|
||||||
@@ -215,6 +217,7 @@ export type OpportunityCountAggregateOutputType = {
|
|||||||
companyId: number
|
companyId: number
|
||||||
productSequence: number
|
productSequence: number
|
||||||
cwLastUpdated: number
|
cwLastUpdated: number
|
||||||
|
cwDateEntered: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
_all: number
|
_all: number
|
||||||
@@ -309,6 +312,7 @@ export type OpportunityMinAggregateInputType = {
|
|||||||
closedByCwId?: true
|
closedByCwId?: true
|
||||||
companyId?: true
|
companyId?: true
|
||||||
cwLastUpdated?: true
|
cwLastUpdated?: true
|
||||||
|
cwDateEntered?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
}
|
}
|
||||||
@@ -359,6 +363,7 @@ export type OpportunityMaxAggregateInputType = {
|
|||||||
closedByCwId?: true
|
closedByCwId?: true
|
||||||
companyId?: true
|
companyId?: true
|
||||||
cwLastUpdated?: true
|
cwLastUpdated?: true
|
||||||
|
cwDateEntered?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
}
|
}
|
||||||
@@ -410,6 +415,7 @@ export type OpportunityCountAggregateInputType = {
|
|||||||
companyId?: true
|
companyId?: true
|
||||||
productSequence?: true
|
productSequence?: true
|
||||||
cwLastUpdated?: true
|
cwLastUpdated?: true
|
||||||
|
cwDateEntered?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
_all?: true
|
_all?: true
|
||||||
@@ -548,6 +554,7 @@ export type OpportunityGroupByOutputType = {
|
|||||||
companyId: string | null
|
companyId: string | null
|
||||||
productSequence: number[]
|
productSequence: number[]
|
||||||
cwLastUpdated: Date | null
|
cwLastUpdated: Date | null
|
||||||
|
cwDateEntered: Date | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
_count: OpportunityCountAggregateOutputType | null
|
_count: OpportunityCountAggregateOutputType | null
|
||||||
@@ -622,6 +629,7 @@ export type OpportunityWhereInput = {
|
|||||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
||||||
@@ -675,6 +683,7 @@ export type OpportunityOrderByWithRelationInput = {
|
|||||||
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
productSequence?: Prisma.SortOrder
|
productSequence?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
cwDateEntered?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
|
generatedQuotes?: Prisma.GeneratedQuotesOrderByRelationAggregateInput
|
||||||
@@ -731,6 +740,7 @@ export type OpportunityWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
generatedQuotes?: Prisma.GeneratedQuotesListRelationFilter
|
||||||
@@ -784,6 +794,7 @@ export type OpportunityOrderByWithAggregationInput = {
|
|||||||
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
companyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
productSequence?: Prisma.SortOrder
|
productSequence?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
cwDateEntered?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
_count?: Prisma.OpportunityCountOrderByAggregateInput
|
_count?: Prisma.OpportunityCountOrderByAggregateInput
|
||||||
@@ -843,6 +854,7 @@ export type OpportunityScalarWhereWithAggregatesInput = {
|
|||||||
companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
companyId?: Prisma.StringNullableWithAggregatesFilter<"Opportunity"> | string | null
|
||||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||||
cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
|
cwLastUpdated?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.DateTimeNullableWithAggregatesFilter<"Opportunity"> | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Opportunity"> | Date | string
|
||||||
}
|
}
|
||||||
@@ -893,6 +905,7 @@ export type OpportunityCreateInput = {
|
|||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
|
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
|
||||||
@@ -946,6 +959,7 @@ export type OpportunityUncheckedCreateInput = {
|
|||||||
companyId?: string | null
|
companyId?: string | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
|
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
|
||||||
@@ -997,6 +1011,7 @@ export type OpportunityUpdateInput = {
|
|||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
|
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
|
||||||
@@ -1050,6 +1065,7 @@ export type OpportunityUncheckedUpdateInput = {
|
|||||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
|
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
|
||||||
@@ -1102,6 +1118,7 @@ export type OpportunityCreateManyInput = {
|
|||||||
companyId?: string | null
|
companyId?: string | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
}
|
}
|
||||||
@@ -1152,6 +1169,7 @@ export type OpportunityUpdateManyMutationInput = {
|
|||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
}
|
}
|
||||||
@@ -1203,6 +1221,7 @@ export type OpportunityUncheckedUpdateManyInput = {
|
|||||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
}
|
}
|
||||||
@@ -1272,6 +1291,7 @@ export type OpportunityCountOrderByAggregateInput = {
|
|||||||
companyId?: Prisma.SortOrder
|
companyId?: Prisma.SortOrder
|
||||||
productSequence?: Prisma.SortOrder
|
productSequence?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrder
|
||||||
|
cwDateEntered?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
@@ -1343,6 +1363,7 @@ export type OpportunityMaxOrderByAggregateInput = {
|
|||||||
closedByCwId?: Prisma.SortOrder
|
closedByCwId?: Prisma.SortOrder
|
||||||
companyId?: Prisma.SortOrder
|
companyId?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrder
|
||||||
|
cwDateEntered?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
@@ -1393,6 +1414,7 @@ export type OpportunityMinOrderByAggregateInput = {
|
|||||||
closedByCwId?: Prisma.SortOrder
|
closedByCwId?: Prisma.SortOrder
|
||||||
companyId?: Prisma.SortOrder
|
companyId?: Prisma.SortOrder
|
||||||
cwLastUpdated?: Prisma.SortOrder
|
cwLastUpdated?: Prisma.SortOrder
|
||||||
|
cwDateEntered?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
@@ -1534,6 +1556,7 @@ export type OpportunityCreateWithoutCompanyInput = {
|
|||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
|
generatedQuotes?: Prisma.GeneratedQuotesCreateNestedManyWithoutOpportunityInput
|
||||||
@@ -1585,6 +1608,7 @@ export type OpportunityUncheckedCreateWithoutCompanyInput = {
|
|||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
|
generatedQuotes?: Prisma.GeneratedQuotesUncheckedCreateNestedManyWithoutOpportunityInput
|
||||||
@@ -1666,6 +1690,7 @@ export type OpportunityScalarWhereInput = {
|
|||||||
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
companyId?: Prisma.StringNullableFilter<"Opportunity"> | string | null
|
||||||
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
productSequence?: Prisma.IntNullableListFilter<"Opportunity">
|
||||||
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
cwLastUpdated?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.DateTimeNullableFilter<"Opportunity"> | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Opportunity"> | Date | string
|
||||||
}
|
}
|
||||||
@@ -1716,6 +1741,7 @@ export type OpportunityCreateWithoutGeneratedQuotesInput = {
|
|||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
|
company?: Prisma.CompanyCreateNestedOneWithoutOpportunitiesInput
|
||||||
@@ -1768,6 +1794,7 @@ export type OpportunityUncheckedCreateWithoutGeneratedQuotesInput = {
|
|||||||
companyId?: string | null
|
companyId?: string | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
}
|
}
|
||||||
@@ -1834,6 +1861,7 @@ export type OpportunityUpdateWithoutGeneratedQuotesInput = {
|
|||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
|
company?: Prisma.CompanyUpdateOneWithoutOpportunitiesNestedInput
|
||||||
@@ -1886,6 +1914,7 @@ export type OpportunityUncheckedUpdateWithoutGeneratedQuotesInput = {
|
|||||||
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
companyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
}
|
}
|
||||||
@@ -1936,6 +1965,7 @@ export type OpportunityCreateManyCompanyInput = {
|
|||||||
closedByCwId?: number | null
|
closedByCwId?: number | null
|
||||||
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityCreateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Date | string | null
|
cwLastUpdated?: Date | string | null
|
||||||
|
cwDateEntered?: Date | string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
}
|
}
|
||||||
@@ -1986,6 +2016,7 @@ export type OpportunityUpdateWithoutCompanyInput = {
|
|||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
|
generatedQuotes?: Prisma.GeneratedQuotesUpdateManyWithoutOpportunityNestedInput
|
||||||
@@ -2037,6 +2068,7 @@ export type OpportunityUncheckedUpdateWithoutCompanyInput = {
|
|||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
|
generatedQuotes?: Prisma.GeneratedQuotesUncheckedUpdateManyWithoutOpportunityNestedInput
|
||||||
@@ -2088,6 +2120,7 @@ export type OpportunityUncheckedUpdateManyWithoutCompanyInput = {
|
|||||||
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
closedByCwId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
productSequence?: Prisma.OpportunityUpdateproductSequenceInput | number[]
|
||||||
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
cwLastUpdated?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
cwDateEntered?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
}
|
}
|
||||||
@@ -2170,6 +2203,7 @@ export type OpportunitySelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
|||||||
companyId?: boolean
|
companyId?: boolean
|
||||||
productSequence?: boolean
|
productSequence?: boolean
|
||||||
cwLastUpdated?: boolean
|
cwLastUpdated?: boolean
|
||||||
|
cwDateEntered?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
|
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
|
||||||
@@ -2224,6 +2258,7 @@ export type OpportunitySelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
|||||||
companyId?: boolean
|
companyId?: boolean
|
||||||
productSequence?: boolean
|
productSequence?: boolean
|
||||||
cwLastUpdated?: boolean
|
cwLastUpdated?: boolean
|
||||||
|
cwDateEntered?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||||
@@ -2276,6 +2311,7 @@ export type OpportunitySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
|||||||
companyId?: boolean
|
companyId?: boolean
|
||||||
productSequence?: boolean
|
productSequence?: boolean
|
||||||
cwLastUpdated?: boolean
|
cwLastUpdated?: boolean
|
||||||
|
cwDateEntered?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||||
@@ -2328,11 +2364,12 @@ export type OpportunitySelectScalar = {
|
|||||||
companyId?: boolean
|
companyId?: boolean
|
||||||
productSequence?: boolean
|
productSequence?: boolean
|
||||||
cwLastUpdated?: boolean
|
cwLastUpdated?: boolean
|
||||||
|
cwDateEntered?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "probability" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "productSequence" | "cwLastUpdated" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
|
export type OpportunityOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "cwOpportunityId" | "name" | "notes" | "typeName" | "typeCwId" | "stageName" | "stageCwId" | "statusName" | "statusCwId" | "priorityName" | "priorityCwId" | "ratingName" | "ratingCwId" | "source" | "campaignName" | "campaignCwId" | "primarySalesRepName" | "primarySalesRepIdentifier" | "primarySalesRepCwId" | "secondarySalesRepName" | "secondarySalesRepIdentifier" | "secondarySalesRepCwId" | "companyCwId" | "companyName" | "contactCwId" | "contactName" | "siteCwId" | "siteName" | "customerPO" | "totalSalesTax" | "probability" | "locationName" | "locationCwId" | "departmentName" | "departmentCwId" | "expectedCloseDate" | "pipelineChangeDate" | "dateBecameLead" | "closedDate" | "closedFlag" | "closedByName" | "closedByCwId" | "companyId" | "productSequence" | "cwLastUpdated" | "cwDateEntered" | "createdAt" | "updatedAt", ExtArgs["result"]["opportunity"]>
|
||||||
export type OpportunityInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type OpportunityInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
|
generatedQuotes?: boolean | Prisma.Opportunity$generatedQuotesArgs<ExtArgs>
|
||||||
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
company?: boolean | Prisma.Opportunity$companyArgs<ExtArgs>
|
||||||
@@ -2398,6 +2435,7 @@ export type $OpportunityPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
|||||||
companyId: string | null
|
companyId: string | null
|
||||||
productSequence: number[]
|
productSequence: number[]
|
||||||
cwLastUpdated: Date | null
|
cwLastUpdated: Date | null
|
||||||
|
cwDateEntered: Date | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
}, ExtArgs["result"]["opportunity"]>
|
}, ExtArgs["result"]["opportunity"]>
|
||||||
@@ -2871,6 +2909,7 @@ export interface OpportunityFieldRefs {
|
|||||||
readonly companyId: Prisma.FieldRef<"Opportunity", 'String'>
|
readonly companyId: Prisma.FieldRef<"Opportunity", 'String'>
|
||||||
readonly productSequence: Prisma.FieldRef<"Opportunity", 'Int[]'>
|
readonly productSequence: Prisma.FieldRef<"Opportunity", 'Int[]'>
|
||||||
readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
readonly cwLastUpdated: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||||
|
readonly cwDateEntered: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||||
readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
readonly createdAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||||
readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
readonly updatedAt: Prisma.FieldRef<"Opportunity", 'DateTime'>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CwMember" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"cwMemberId" INTEGER NOT NULL,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"officeEmail" TEXT,
|
||||||
|
"inactiveFlag" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"apiKey" TEXT,
|
||||||
|
"cwLastUpdated" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CwMember_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CwMember_cwMemberId_key" ON "CwMember"("cwMemberId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CwMember_identifier_key" ON "CwMember"("identifier");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Opportunity" ADD COLUMN "cwDateEntered" TIMESTAMP(3);
|
||||||
@@ -194,6 +194,7 @@ model Opportunity {
|
|||||||
productSequence Int[] @default([])
|
productSequence Int[] @default([])
|
||||||
|
|
||||||
cwLastUpdated DateTime?
|
cwLastUpdated DateTime?
|
||||||
|
cwDateEntered DateTime?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ import {
|
|||||||
} from "../../../modules/cache/opportunityCache";
|
} from "../../../modules/cache/opportunityCache";
|
||||||
import { generatedQuotes } from "../../../managers/generatedQuotes";
|
import { generatedQuotes } from "../../../managers/generatedQuotes";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
|
/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier"],
|
["/opportunities/opportunity/:identifier"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const includeParam = c.req.query("include") ?? "";
|
const includeParam = c.req.query("include") ?? "";
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { default as fetchAll } from "./opportunities/fetchAll";
|
import { default as fetchAll } from "./opportunities/fetchAll";
|
||||||
|
import { default as metrics } from "./opportunities/metrics";
|
||||||
import { default as createOpportunity } from "./opportunities/create";
|
import { default as createOpportunity } from "./opportunities/create";
|
||||||
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
|
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
|
||||||
import { default as count } from "./opportunities/count";
|
import { default as count } from "./opportunities/count";
|
||||||
@@ -26,18 +27,23 @@ import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
|
|||||||
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
|
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
|
||||||
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
|
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
|
||||||
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
|
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
|
||||||
|
import { default as fetchByUser } from "./opportunities/fetchByUser";
|
||||||
|
import { default as fetchByUserId } from "./opportunities/fetchByUserId";
|
||||||
import { default as workflowDispatch } from "./opportunities/[id]/workflow/dispatch";
|
import { default as workflowDispatch } from "./opportunities/[id]/workflow/dispatch";
|
||||||
import { default as workflowStatus } from "./opportunities/[id]/workflow/status";
|
import { default as workflowStatus } from "./opportunities/[id]/workflow/status";
|
||||||
import { default as workflowHistory } from "./opportunities/[id]/workflow/history";
|
import { default as workflowHistory } from "./opportunities/[id]/workflow/history";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
addProduct,
|
addProduct,
|
||||||
|
fetchByUser,
|
||||||
|
fetchByUserId,
|
||||||
addLabor,
|
addLabor,
|
||||||
laborOptions,
|
laborOptions,
|
||||||
addSpecialOrderProduct,
|
addSpecialOrderProduct,
|
||||||
count,
|
count,
|
||||||
createOpportunity,
|
createOpportunity,
|
||||||
deleteOpportunity,
|
deleteOpportunity,
|
||||||
|
metrics,
|
||||||
fetch,
|
fetch,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
fetchOpportunityTypes,
|
fetchOpportunityTypes,
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../middleware/authorization";
|
import { authMiddleware } from "../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/contacts */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/contacts */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/contacts"],
|
["/opportunities/opportunity/:identifier/contacts"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const item = await opportunities.fetchRecord(identifier);
|
const item = await opportunities.fetchRecord(identifier);
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
|||||||
import { authMiddleware } from "../../../middleware/authorization";
|
import { authMiddleware } from "../../../middleware/authorization";
|
||||||
import GenericError from "../../../../Errors/GenericError";
|
import GenericError from "../../../../Errors/GenericError";
|
||||||
|
|
||||||
/* DELETE /v1/sales/opportunities/:identifier */
|
/* DELETE /v1/sales/opportunities/opportunity/:identifier */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"delete",
|
"delete",
|
||||||
["/opportunities/:identifier"],
|
["/opportunities/opportunity/:identifier"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ import {
|
|||||||
} from "../../../../modules/cache/opportunityCache";
|
} from "../../../../modules/cache/opportunityCache";
|
||||||
import { generatedQuotes } from "../../../../managers/generatedQuotes";
|
import { generatedQuotes } from "../../../../managers/generatedQuotes";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
|
/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier"],
|
["/opportunities/opportunity/:identifier"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const includeParam = c.req.query("include") ?? "";
|
const includeParam = c.req.query("include") ?? "";
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { authMiddleware } from "../../../../middleware/authorization";
|
|||||||
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/* POST /v1/sales/opportunities/:identifier/notes */
|
/* POST /v1/sales/opportunities/opportunity/:identifier/notes */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/notes"],
|
["/opportunities/opportunity/:identifier/notes"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
|||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
import GenericError from "../../../../../Errors/GenericError";
|
import GenericError from "../../../../../Errors/GenericError";
|
||||||
|
|
||||||
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
|
/* DELETE /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"delete",
|
"delete",
|
||||||
["/opportunities/:identifier/notes/:noteId"],
|
["/opportunities/opportunity/:identifier/notes/:noteId"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const noteId = Number(c.req.param("noteId"));
|
const noteId = Number(c.req.param("noteId"));
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
|||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
import GenericError from "../../../../../Errors/GenericError";
|
import GenericError from "../../../../../Errors/GenericError";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/notes/:noteId"],
|
["/opportunities/opportunity/:identifier/notes/:noteId"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const noteId = Number(c.req.param("noteId"));
|
const noteId = Number(c.req.param("noteId"));
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/notes */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/notes */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/notes"],
|
["/opportunities/opportunity/:identifier/notes"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const item = await opportunities.fetchRecord(identifier);
|
const item = await opportunities.fetchRecord(identifier);
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import GenericError from "../../../../../Errors/GenericError";
|
|||||||
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
|
/* PATCH /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"patch",
|
"patch",
|
||||||
["/opportunities/:identifier/notes/:noteId"],
|
["/opportunities/opportunity/:identifier/notes/:noteId"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const noteId = Number(c.req.param("noteId"));
|
const noteId = Number(c.req.param("noteId"));
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ const addProductSchema = z.union([
|
|||||||
z.array(productItemSchema).min(1, "At least one product is required"),
|
z.array(productItemSchema).min(1, "At least one product is required"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/* POST /v1/sales/opportunities/:identifier/products */
|
/* POST /v1/sales/opportunities/opportunity/:identifier/products */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/products"],
|
["/opportunities/opportunity/:identifier/products"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ const addLaborSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
/* POST /v1/sales/opportunities/:identifier/products/labor */
|
/* POST /v1/sales/opportunities/opportunity/:identifier/products/labor */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/products/labor"],
|
["/opportunities/opportunity/:identifier/products/labor"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ const addSpecialOrderSchema = z.union([
|
|||||||
.min(1, "At least one special-order product is required"),
|
.min(1, "At least one special-order product is required"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/* POST /v1/sales/opportunities/:identifier/products/special-order */
|
/* POST /v1/sales/opportunities/opportunity/:identifier/products/special-order */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/products/special-order"],
|
["/opportunities/opportunity/:identifier/products/special-order"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ const cancelProductSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/cancel */
|
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/cancel */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"patch",
|
"patch",
|
||||||
["/opportunities/:identifier/products/:productId/cancel"],
|
["/opportunities/opportunity/:identifier/products/:productId/cancel"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const productId = Number(c.req.param("productId"));
|
const productId = Number(c.req.param("productId"));
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
|||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
import GenericError from "../../../../../Errors/GenericError";
|
import GenericError from "../../../../../Errors/GenericError";
|
||||||
|
|
||||||
/* DELETE /v1/sales/opportunities/:identifier/products/:productId */
|
/* DELETE /v1/sales/opportunities/opportunity/:identifier/products/:productId */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"delete",
|
"delete",
|
||||||
["/opportunities/:identifier/products/:productId"],
|
["/opportunities/opportunity/:identifier/products/:productId"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const productId = Number(c.req.param("productId"));
|
const productId = Number(c.req.param("productId"));
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/products */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/products */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/products"],
|
["/opportunities/opportunity/:identifier/products"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const item = await opportunities.fetchRecord(identifier);
|
const item = await opportunities.fetchRecord(identifier);
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/products/labor/options */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/products/labor/options */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/products/labor/options"],
|
["/opportunities/opportunity/:identifier/products/labor/options"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
|
|||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
|
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/sequence */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"patch",
|
"patch",
|
||||||
["/opportunities/:identifier/products/sequence"],
|
["/opportunities/opportunity/:identifier/products/sequence"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const updateProductSchema = z
|
|||||||
customerDescription: z.string().nullable().optional(),
|
customerDescription: z.string().nullable().optional(),
|
||||||
productNarrative: z.string().nullable().optional(),
|
productNarrative: z.string().nullable().optional(),
|
||||||
procurementNotes: z.string().nullable().optional(),
|
procurementNotes: z.string().nullable().optional(),
|
||||||
|
taxableFlag: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
@@ -55,10 +56,10 @@ const upsertCustomTextField = (
|
|||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/edit */
|
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/edit */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"patch",
|
"patch",
|
||||||
["/opportunities/:identifier/products/:productId/edit"],
|
["/opportunities/opportunity/:identifier/products/:productId/edit"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const productId = Number(c.req.param("productId"));
|
const productId = Number(c.req.param("productId"));
|
||||||
@@ -108,6 +109,9 @@ export default createRoute(
|
|||||||
(input.unitCost * effectiveQuantity).toFixed(2),
|
(input.unitCost * effectiveQuantity).toFixed(2),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (input.taxableFlag !== undefined) {
|
||||||
|
forecastPatch.taxableFlag = input.taxableFlag;
|
||||||
|
}
|
||||||
|
|
||||||
const existingProcurement =
|
const existingProcurement =
|
||||||
await opportunity.fetchProcurementProductByForecastItem(productId);
|
await opportunity.fetchProcurementProductByForecastItem(productId);
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ const commitQuoteSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
/* POST /v1/sales/opportunities/:identifier/quote/commit */
|
/* POST /v1/sales/opportunities/opportunity/:identifier/quote/commit */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/quote/commit"],
|
["/opportunities/opportunity/:identifier/quote/commit"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json().catch(() => undefined);
|
const body = await c.req.json().catch(() => undefined);
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import GenericError from "../../../../../Errors/GenericError";
|
|||||||
const VALID_FETCH_ACTIONS = ["download", "print"] as const;
|
const VALID_FETCH_ACTIONS = ["download", "print"] as const;
|
||||||
type FetchAction = (typeof VALID_FETCH_ACTIONS)[number];
|
type FetchAction = (typeof VALID_FETCH_ACTIONS)[number];
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/download?fetchAction=download|print */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/download?fetchAction=download|print */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/quote/:quoteId/download"],
|
["/opportunities/opportunity/:identifier/quote/:quoteId/download"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const quoteId = c.req.param("quoteId");
|
const quoteId = c.req.param("quoteId");
|
||||||
const user = c.get("user");
|
const user = c.get("user");
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/quotes */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/quotes */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/quotes"],
|
["/opportunities/opportunity/:identifier/quotes"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const includeRegenData = c.req.query("includeRegenData") === "true";
|
const includeRegenData = c.req.query("includeRegenData") === "true";
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/quotes/downloads */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/quotes/downloads */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/quotes/downloads"],
|
["/opportunities/opportunity/:identifier/quotes/downloads"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../../middleware/authorization";
|
import { authMiddleware } from "../../../../middleware/authorization";
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/preview */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/preview */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/quote/:quoteId/preview"],
|
["/opportunities/opportunity/:identifier/quote/:quoteId/preview"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const quoteId = c.req.param("quoteId");
|
const quoteId = c.req.param("quoteId");
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../modules/api-utils/apiResponse";
|
|||||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
import { authMiddleware } from "../../../middleware/authorization";
|
import { authMiddleware } from "../../../middleware/authorization";
|
||||||
|
|
||||||
/* POST /v1/sales/opportunities/:identifier/refresh */
|
/* POST /v1/sales/opportunities/opportunity/:identifier/refresh */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/refresh"],
|
["/opportunities/opportunity/:identifier/refresh"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const item = await opportunities.fetchItem(identifier);
|
const item = await opportunities.fetchItem(identifier);
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ const updateSchema = z
|
|||||||
message: "At least one field must be provided",
|
message: "At least one field must be provided",
|
||||||
});
|
});
|
||||||
|
|
||||||
/* PATCH /v1/sales/opportunities/:identifier */
|
/* PATCH /v1/sales/opportunities/opportunity/:identifier */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"patch",
|
"patch",
|
||||||
["/opportunities/:identifier"],
|
["/opportunities/opportunity/:identifier"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ const dispatchSchema = z.discriminatedUnion("action", [
|
|||||||
needsRevision: z.boolean().optional(),
|
needsRevision: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
action: z.literal("markReadyToSend"),
|
||||||
|
payload: basePayload,
|
||||||
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
action: z.literal("confirmQuote"),
|
action: z.literal("confirmQuote"),
|
||||||
payload: basePayload,
|
payload: basePayload,
|
||||||
@@ -91,10 +95,10 @@ const dispatchSchema = z.discriminatedUnion("action", [
|
|||||||
|
|
||||||
// ── Route ─────────────────────────────────────────────────────────────────
|
// ── Route ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/* POST /v1/sales/opportunities/:identifier/workflow */
|
/* POST /v1/sales/opportunities/opportunity/:identifier/workflow */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"post",
|
"post",
|
||||||
["/opportunities/:identifier/workflow"],
|
["/opportunities/opportunity/:identifier/workflow"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ function extractCloseDate(
|
|||||||
// ROUTE
|
// ROUTE
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/workflow/history */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/workflow/history */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/workflow/history"],
|
["/opportunities/opportunity/:identifier/workflow/history"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
[OpportunityStatus.New]: [
|
[OpportunityStatus.New]: [
|
||||||
|
{
|
||||||
|
action: "markReadyToSend",
|
||||||
|
label: "Mark Ready to Send",
|
||||||
|
targetStatuses: [
|
||||||
|
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
|
||||||
|
],
|
||||||
|
requiresNote: false,
|
||||||
|
requiresPermission: null,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: "sendQuote",
|
action: "sendQuote",
|
||||||
label: "Send Quote (skip review)",
|
label: "Send Quote (skip review)",
|
||||||
@@ -81,9 +90,9 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
|
|||||||
[OpportunityStatus.InternalReview]: [
|
[OpportunityStatus.InternalReview]: [
|
||||||
{
|
{
|
||||||
action: "reviewDecision",
|
action: "reviewDecision",
|
||||||
label: "Approve (move to Pending Sent)",
|
label: "Approve (move to Ready to Send)",
|
||||||
targetStatuses: [
|
targetStatuses: [
|
||||||
{ key: "PendingSent", id: OpportunityStatus.PendingSent },
|
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
|
||||||
],
|
],
|
||||||
requiresNote: true,
|
requiresNote: true,
|
||||||
requiresPermission: null,
|
requiresPermission: null,
|
||||||
@@ -117,7 +126,32 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
[OpportunityStatus.ReadyToSend]: [
|
||||||
|
{
|
||||||
|
action: "sendQuote",
|
||||||
|
label: "Send Quote to Customer",
|
||||||
|
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
|
||||||
|
requiresNote: false,
|
||||||
|
requiresPermission: null,
|
||||||
|
payloadHints: {
|
||||||
|
quoteConfirmed: "boolean — mark confirmed simultaneously",
|
||||||
|
won: "boolean — immediate win",
|
||||||
|
lost: "boolean — immediate rejection",
|
||||||
|
needsRevision: "boolean — needs revision",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
[OpportunityStatus.PendingSent]: [
|
[OpportunityStatus.PendingSent]: [
|
||||||
|
{
|
||||||
|
action: "markReadyToSend",
|
||||||
|
label: "Mark Ready to Send",
|
||||||
|
targetStatuses: [
|
||||||
|
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
|
||||||
|
],
|
||||||
|
requiresNote: false,
|
||||||
|
requiresPermission: null,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: "sendQuote",
|
action: "sendQuote",
|
||||||
label: "Send Quote to Customer",
|
label: "Send Quote to Customer",
|
||||||
@@ -217,6 +251,15 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
[OpportunityStatus.Active]: [
|
[OpportunityStatus.Active]: [
|
||||||
|
{
|
||||||
|
action: "markReadyToSend",
|
||||||
|
label: "Mark Ready to Send",
|
||||||
|
targetStatuses: [
|
||||||
|
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
|
||||||
|
],
|
||||||
|
requiresNote: false,
|
||||||
|
requiresPermission: null,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: "resendQuote",
|
action: "resendQuote",
|
||||||
label: "Send Revised Quote",
|
label: "Send Revised Quote",
|
||||||
@@ -294,10 +337,10 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
|
|||||||
// ROUTE
|
// ROUTE
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/* GET /v1/sales/opportunities/:identifier/workflow */
|
/* GET /v1/sales/opportunities/opportunity/:identifier/workflow */
|
||||||
export default createRoute(
|
export default createRoute(
|
||||||
"get",
|
"get",
|
||||||
["/opportunities/:identifier/workflow"],
|
["/opportunities/opportunity/:identifier/workflow"],
|
||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const identifier = c.req.param("identifier");
|
const identifier = c.req.param("identifier");
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/@me */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/@me"],
|
||||||
|
async (c) => {
|
||||||
|
const user = c.get("user");
|
||||||
|
const includeClosed = c.req.query("includeClosed") === "true";
|
||||||
|
|
||||||
|
const data = await opportunities.fetchByUser(user.id, { includeClosed });
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Opportunities fetched successfully!",
|
||||||
|
data.map((item) => item.toJson()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { opportunities } from "../../../managers/opportunities";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/user/:id */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/user/:id"],
|
||||||
|
async (c) => {
|
||||||
|
const userId = c.req.param("id");
|
||||||
|
const includeClosed = c.req.query("includeClosed") === "true";
|
||||||
|
|
||||||
|
const data = await opportunities.fetchByUser(userId, { includeClosed });
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Opportunities fetched successfully!",
|
||||||
|
data.map((item) => item.toJson()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
|
||||||
|
);
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||||
|
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||||
|
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||||
|
import { authMiddleware } from "../../middleware/authorization";
|
||||||
|
import GenericError from "../../../Errors/GenericError";
|
||||||
|
import {
|
||||||
|
getSalesOpportunityMetricsAll,
|
||||||
|
getSalesOpportunityMetricsForMember,
|
||||||
|
refreshSalesOpportunityMetricsCache,
|
||||||
|
} from "../../../modules/cache/salesOpportunityMetricsCache";
|
||||||
|
|
||||||
|
/* GET /v1/sales/opportunities/metrics */
|
||||||
|
export default createRoute(
|
||||||
|
"get",
|
||||||
|
["/opportunities/metrics"],
|
||||||
|
async (c) => {
|
||||||
|
const user = c.get("user");
|
||||||
|
const scope = (c.req.query("scope") ?? "me").toLowerCase();
|
||||||
|
const requestedIdentifier = c.req.query("identifier")?.trim().toLowerCase();
|
||||||
|
const currentUserIdentifier = user?.cwIdentifier?.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
scope === "all" &&
|
||||||
|
!(await user.hasPermission("sales.opportunity.metrics.all"))
|
||||||
|
) {
|
||||||
|
throw new GenericError({
|
||||||
|
name: "InsufficientPermission",
|
||||||
|
message:
|
||||||
|
"You do not have permission to view metrics for all active members.",
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const usingIdentifierOverride =
|
||||||
|
scope !== "all" &&
|
||||||
|
!!requestedIdentifier &&
|
||||||
|
requestedIdentifier !== currentUserIdentifier;
|
||||||
|
|
||||||
|
if (
|
||||||
|
usingIdentifierOverride &&
|
||||||
|
!(await user.hasPermission(
|
||||||
|
"sales.opportunity.metrics.identifier.override",
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
throw new GenericError({
|
||||||
|
name: "InsufficientPermission",
|
||||||
|
message:
|
||||||
|
"You do not have permission to query metrics by overriding the member identifier.",
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const requireWarmCache = async () => {
|
||||||
|
const all = await getSalesOpportunityMetricsAll();
|
||||||
|
if (all) return all;
|
||||||
|
await refreshSalesOpportunityMetricsCache();
|
||||||
|
return getSalesOpportunityMetricsAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (scope === "all") {
|
||||||
|
const all = await requireWarmCache();
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Sales opportunity metrics fetched successfully!",
|
||||||
|
all,
|
||||||
|
);
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIdentifier = requestedIdentifier ?? currentUserIdentifier;
|
||||||
|
|
||||||
|
if (!targetIdentifier) {
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Sales opportunity metrics fetched successfully!",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
let metrics = await getSalesOpportunityMetricsForMember(targetIdentifier);
|
||||||
|
if (!metrics) {
|
||||||
|
await refreshSalesOpportunityMetricsCache();
|
||||||
|
metrics = await getSalesOpportunityMetricsForMember(targetIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = apiResponse.successful(
|
||||||
|
"Sales opportunity metrics fetched successfully!",
|
||||||
|
{
|
||||||
|
identifier: targetIdentifier,
|
||||||
|
metrics,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return c.json(response, response.status as ContentfulStatusCode);
|
||||||
|
},
|
||||||
|
authMiddleware({ permissions: ["sales.opportunity.fetch.many"] }),
|
||||||
|
);
|
||||||
@@ -45,6 +45,8 @@ import {
|
|||||||
type QuoteMetadata,
|
type QuoteMetadata,
|
||||||
} from "../modules/pdf-utils";
|
} from "../modules/pdf-utils";
|
||||||
import { generatedQuotes } from "../managers/generatedQuotes";
|
import { generatedQuotes } from "../managers/generatedQuotes";
|
||||||
|
import { getExpectedSalesTaxRate } from "../modules/sales-utils/expectedSalesTax";
|
||||||
|
import { normalizeProbabilityPercent } from "../modules/sales-utils/normalizeProbability";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opportunity Controller
|
* Opportunity Controller
|
||||||
@@ -106,6 +108,7 @@ export class OpportunityController {
|
|||||||
|
|
||||||
public companyId: string | null;
|
public companyId: string | null;
|
||||||
public cwLastUpdated: Date | null;
|
public cwLastUpdated: Date | null;
|
||||||
|
public cwDateEntered: Date | null;
|
||||||
|
|
||||||
// Local product display order — array of CW forecast item IDs.
|
// Local product display order — array of CW forecast item IDs.
|
||||||
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
|
// When non-empty, fetchProducts() uses this instead of CW sequenceNumber.
|
||||||
@@ -223,6 +226,7 @@ export class OpportunityController {
|
|||||||
|
|
||||||
this.companyId = data.companyId;
|
this.companyId = data.companyId;
|
||||||
this.cwLastUpdated = data.cwLastUpdated;
|
this.cwLastUpdated = data.cwLastUpdated;
|
||||||
|
this.cwDateEntered = data.cwDateEntered ?? null;
|
||||||
this.productSequence = data.productSequence;
|
this.productSequence = data.productSequence;
|
||||||
|
|
||||||
this.createdAt = data.createdAt;
|
this.createdAt = data.createdAt;
|
||||||
@@ -366,7 +370,7 @@ export class OpportunityController {
|
|||||||
customerPO: item.customerPO ?? null,
|
customerPO: item.customerPO ?? null,
|
||||||
|
|
||||||
totalSalesTax: item.totalSalesTax ?? 0,
|
totalSalesTax: item.totalSalesTax ?? 0,
|
||||||
probability: Number(item.probability?.name) || 0,
|
probability: normalizeProbabilityPercent(item.probability?.name),
|
||||||
|
|
||||||
locationName: item.location?.name ?? null,
|
locationName: item.location?.name ?? null,
|
||||||
locationCwId: item.location?.id ?? null,
|
locationCwId: item.location?.id ?? null,
|
||||||
@@ -390,6 +394,9 @@ export class OpportunityController {
|
|||||||
cwLastUpdated: item._info?.lastUpdated
|
cwLastUpdated: item._info?.lastUpdated
|
||||||
? new Date(item._info.lastUpdated)
|
? new Date(item._info.lastUpdated)
|
||||||
: new Date(),
|
: new Date(),
|
||||||
|
cwDateEntered: item._info?.dateEntered
|
||||||
|
? new Date(item._info.dateEntered)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,6 +697,17 @@ export class OpportunityController {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const taxableSubTotal = activeProducts.reduce((sum, item) => {
|
||||||
|
if (!item.taxableFlag) return sum;
|
||||||
|
|
||||||
|
const isLabor = item.productClass === "Service";
|
||||||
|
const quantity = item.effectiveQuantity > 0 ? item.effectiveQuantity : 1;
|
||||||
|
const lineTotal = Number.isFinite(item.revenue) ? item.revenue : 0;
|
||||||
|
const unitPrice = isLabor ? lineTotal : lineTotal / quantity;
|
||||||
|
|
||||||
|
return sum + (isLabor ? 1 : quantity) * unitPrice;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
const quoteDescription = this.name;
|
const quoteDescription = this.name;
|
||||||
|
|
||||||
const primaryContactFullName = [
|
const primaryContactFullName = [
|
||||||
@@ -705,12 +723,9 @@ export class OpportunityController {
|
|||||||
this.companyName ||
|
this.companyName ||
|
||||||
"Customer";
|
"Customer";
|
||||||
|
|
||||||
const subTotal = lineItems.reduce(
|
const normalizedTaxRate = getExpectedSalesTaxRate(
|
||||||
(sum, item) => sum + item.qty * item.unitPrice,
|
site?.address ?? companyJson?.cw_Data?.address,
|
||||||
0,
|
|
||||||
);
|
);
|
||||||
const normalizedTaxRate =
|
|
||||||
subTotal > 0 ? Math.max(0, this.totalSalesTax / subTotal) : 0;
|
|
||||||
const taxLabel =
|
const taxLabel =
|
||||||
normalizedTaxRate > 0
|
normalizedTaxRate > 0
|
||||||
? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)`
|
? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)`
|
||||||
@@ -718,11 +733,20 @@ export class OpportunityController {
|
|||||||
|
|
||||||
await this._hydrateCustomFields();
|
await this._hydrateCustomFields();
|
||||||
|
|
||||||
const quoteNarrative = options.includeQuoteNarrative
|
const quoteNarrativeField = options.includeQuoteNarrative
|
||||||
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
|
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
|
||||||
undefined
|
undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Fall back to the customerDescription of a QUO-Narrative product
|
||||||
|
const quoNarrativeProduct = !quoteNarrativeField
|
||||||
|
? activeProducts.find((p) => p.catalogItemIdentifier === "QUO-Narrative")
|
||||||
|
: undefined;
|
||||||
|
const quoteNarrative =
|
||||||
|
quoteNarrativeField ??
|
||||||
|
quoNarrativeProduct?.customerDescription ??
|
||||||
|
undefined;
|
||||||
|
|
||||||
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
|
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
|
||||||
|
|
||||||
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
|
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
|
||||||
@@ -769,6 +793,7 @@ export class OpportunityController {
|
|||||||
description: quoteDescription,
|
description: quoteDescription,
|
||||||
},
|
},
|
||||||
lineItems,
|
lineItems,
|
||||||
|
taxableSubtotal: taxableSubTotal,
|
||||||
quoteNarrative,
|
quoteNarrative,
|
||||||
tax: {
|
tax: {
|
||||||
rate: normalizedTaxRate,
|
rate: normalizedTaxRate,
|
||||||
@@ -818,11 +843,18 @@ export class OpportunityController {
|
|||||||
const salesRep = await this._resolveSalesRep();
|
const salesRep = await this._resolveSalesRep();
|
||||||
await this._hydrateCustomFields();
|
await this._hydrateCustomFields();
|
||||||
|
|
||||||
const quoteNarrative = quoteOptions.includeQuoteNarrative
|
const quoteNarrativeField = quoteOptions.includeQuoteNarrative
|
||||||
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
|
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
|
||||||
null)
|
null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Fall back to the customerDescription of a QUO-Narrative product
|
||||||
|
const quoNarrativeProduct = !quoteNarrativeField
|
||||||
|
? products.find((p) => p.catalogItemIdentifier === "QUO-Narrative")
|
||||||
|
: undefined;
|
||||||
|
const quoteNarrative =
|
||||||
|
quoteNarrativeField ?? quoNarrativeProduct?.customerDescription ?? null;
|
||||||
|
|
||||||
// ── Pre-generate IDs & timestamps for metadata ───────────────────
|
// ── Pre-generate IDs & timestamps for metadata ───────────────────
|
||||||
const quoteId = crypto.randomUUID();
|
const quoteId = crypto.randomUUID();
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
@@ -1526,6 +1558,21 @@ export class OpportunityController {
|
|||||||
* Serializes the opportunity into a safe, API-friendly object.
|
* Serializes the opportunity into a safe, API-friendly object.
|
||||||
*/
|
*/
|
||||||
public toJson(): Record<string, any> {
|
public toJson(): Record<string, any> {
|
||||||
|
const siteAddress = this._siteData?.address;
|
||||||
|
const companyAddress = this._company?.cw_Data?.company
|
||||||
|
? {
|
||||||
|
line1: this._company.cw_Data.company.addressLine1,
|
||||||
|
line2: this._company.cw_Data.company.addressLine2,
|
||||||
|
city: this._company.cw_Data.company.city,
|
||||||
|
state: this._company.cw_Data.company.state,
|
||||||
|
zip: this._company.cw_Data.company.zip,
|
||||||
|
country: this._company.cw_Data.company.country?.name ?? null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const expectedSalesTax = getExpectedSalesTaxRate(
|
||||||
|
siteAddress ?? companyAddress,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
cwOpportunityId: this.cwOpportunityId,
|
cwOpportunityId: this.cwOpportunityId,
|
||||||
@@ -1581,6 +1628,7 @@ export class OpportunityController {
|
|||||||
: null,
|
: null,
|
||||||
customerPO: this.customerPO,
|
customerPO: this.customerPO,
|
||||||
totalSalesTax: this.totalSalesTax,
|
totalSalesTax: this.totalSalesTax,
|
||||||
|
expectedSalesTax,
|
||||||
probability: this.probability,
|
probability: this.probability,
|
||||||
location: this.locationCwId
|
location: this.locationCwId
|
||||||
? { id: this.locationCwId, name: this.locationName }
|
? { id: this.locationCwId, name: this.locationName }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { refreshInventory } from "./modules/cw-utils/procurement/refreshInventor
|
|||||||
import { listenInventoryAdjustments } from "./modules/cw-utils/procurement/listenInventoryAdjustments";
|
import { listenInventoryAdjustments } from "./modules/cw-utils/procurement/listenInventoryAdjustments";
|
||||||
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
|
import { refreshOpportunities } from "./modules/cw-utils/opportunities/refreshOpportunities";
|
||||||
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
|
import { refreshOpportunityCache } from "./modules/cache/opportunityCache";
|
||||||
|
import { refreshSalesOpportunityMetricsCache } from "./modules/cache/salesOpportunityMetricsCache";
|
||||||
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
|
import { refreshCwIdentifiers } from "./modules/cw-utils/members/refreshCwIdentifiers";
|
||||||
import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers";
|
import { refreshCwMembers } from "./modules/cw-utils/members/refreshCwMembers";
|
||||||
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
|
import { userDefinedFieldsCw } from "./modules/cw-utils/userDefinedFields";
|
||||||
@@ -178,6 +179,22 @@ setInterval(
|
|||||||
5 * 60 * 1000,
|
5 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Refresh sales opportunity metrics cache for active CW members every 5 minutes
|
||||||
|
await safeStartup(
|
||||||
|
"refreshSalesOpportunityMetricsCache",
|
||||||
|
() => refreshSalesOpportunityMetricsCache({ forceColdLoad: true }),
|
||||||
|
);
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
|
return refreshSalesOpportunityMetricsCache().catch((err) =>
|
||||||
|
console.error(
|
||||||
|
`[interval] refreshSalesOpportunityMetricsCache failed: ${briefErr(err)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
5 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
// Refresh CW identifiers for all users every 30 minutes
|
// Refresh CW identifiers for all users every 30 minutes
|
||||||
await safeStartup("refreshCwIdentifiers", refreshCwIdentifiers);
|
await safeStartup("refreshCwIdentifiers", refreshCwIdentifiers);
|
||||||
setInterval(
|
setInterval(
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ export const opportunities = {
|
|||||||
const skip = (Math.max(page, 1) - 1) * rpp;
|
const skip = (Math.max(page, 1) - 1) * rpp;
|
||||||
|
|
||||||
const items = await prisma.opportunity.findMany({
|
const items = await prisma.opportunity.findMany({
|
||||||
where: opts?.includeClosed ? undefined : { closedFlag: false },
|
where: opts?.includeClosed ? undefined : { closedDate: null },
|
||||||
include: { company: true },
|
include: { company: true },
|
||||||
skip,
|
skip,
|
||||||
take: rpp,
|
take: rpp,
|
||||||
@@ -401,7 +401,7 @@ export const opportunities = {
|
|||||||
|
|
||||||
const items = await prisma.opportunity.findMany({
|
const items = await prisma.opportunity.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
...(opts?.includeClosed ? {} : { closedDate: null }),
|
||||||
OR: [
|
OR: [
|
||||||
{ name: { contains: query, mode: "insensitive" } },
|
{ name: { contains: query, mode: "insensitive" } },
|
||||||
{ companyName: { contains: query, mode: "insensitive" } },
|
{ companyName: { contains: query, mode: "insensitive" } },
|
||||||
@@ -445,7 +445,7 @@ export const opportunities = {
|
|||||||
*/
|
*/
|
||||||
async count(opts?: { openOnly?: boolean }): Promise<number> {
|
async count(opts?: { openOnly?: boolean }): Promise<number> {
|
||||||
return prisma.opportunity.count({
|
return prisma.opportunity.count({
|
||||||
where: opts?.openOnly ? { closedFlag: false } : undefined,
|
where: opts?.openOnly ? { closedDate: null } : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -469,7 +469,7 @@ export const opportunities = {
|
|||||||
|
|
||||||
return prisma.opportunity.count({
|
return prisma.opportunity.count({
|
||||||
where: {
|
where: {
|
||||||
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
...(opts?.includeClosed ? {} : { closedDate: null }),
|
||||||
OR: [
|
OR: [
|
||||||
{ name: { contains: query, mode: "insensitive" } },
|
{ name: { contains: query, mode: "insensitive" } },
|
||||||
{ companyName: { contains: query, mode: "insensitive" } },
|
{ companyName: { contains: query, mode: "insensitive" } },
|
||||||
@@ -504,7 +504,7 @@ export const opportunities = {
|
|||||||
const items = await prisma.opportunity.findMany({
|
const items = await prisma.opportunity.findMany({
|
||||||
where: {
|
where: {
|
||||||
companyId,
|
companyId,
|
||||||
...(opts?.includeClosed ? {} : { closedFlag: false }),
|
...(opts?.includeClosed ? {} : { closedDate: null }),
|
||||||
},
|
},
|
||||||
include: { company: true },
|
include: { company: true },
|
||||||
orderBy: { expectedCloseDate: "asc" },
|
orderBy: { expectedCloseDate: "asc" },
|
||||||
@@ -526,6 +526,68 @@ export const opportunities = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Opportunities by User
|
||||||
|
*
|
||||||
|
* Returns all opportunities where the given user (by internal User ID) is
|
||||||
|
* assigned as the primary or secondary sales rep. Resolves the user's
|
||||||
|
* ConnectWise member identifier from the DB, then queries opportunities by
|
||||||
|
* that identifier.
|
||||||
|
*
|
||||||
|
* Uses the **cache-only** strategy (same as `fetchPages`).
|
||||||
|
*
|
||||||
|
* @param userId - Internal User `id` (cuid)
|
||||||
|
* @param opts - Optional filters
|
||||||
|
* @returns {Promise<OpportunityController[]>}
|
||||||
|
*/
|
||||||
|
async fetchByUser(
|
||||||
|
userId: string,
|
||||||
|
opts?: { includeClosed?: boolean },
|
||||||
|
): Promise<OpportunityController[]> {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { cwIdentifier: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new GenericError({
|
||||||
|
message: "User not found",
|
||||||
|
name: "UserNotFound",
|
||||||
|
cause: `No user exists with id '${userId}'`,
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.cwIdentifier) return [];
|
||||||
|
|
||||||
|
const items = await prisma.opportunity.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ primarySalesRepIdentifier: user.cwIdentifier },
|
||||||
|
{ secondarySalesRepIdentifier: user.cwIdentifier },
|
||||||
|
],
|
||||||
|
...(opts?.includeClosed ? {} : { closedDate: null }),
|
||||||
|
},
|
||||||
|
include: { company: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
items.map(async (item) =>
|
||||||
|
new OpportunityController(item, {
|
||||||
|
company: item.company
|
||||||
|
? await buildCompanyController(item.company, {
|
||||||
|
strategy: "cache-only",
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
activities: await buildActivities(item.cwOpportunityId, {
|
||||||
|
strategy: "cache-only",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete Opportunity
|
* Delete Opportunity
|
||||||
*
|
*
|
||||||
|
|||||||
+900
@@ -0,0 +1,900 @@
|
|||||||
|
import { prisma, redis } from "../../constants";
|
||||||
|
import { getCachedOppCwData, getCachedProducts } from "./opportunityCache";
|
||||||
|
import { OpportunityStatus } from "../../workflows/wf.opportunity";
|
||||||
|
import { events } from "../globalEvents";
|
||||||
|
import { opportunities } from "../../managers/opportunities";
|
||||||
|
import { normalizeProbabilityRatio } from "../sales-utils/normalizeProbability";
|
||||||
|
|
||||||
|
const METRICS_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const ALL_MEMBERS_KEY = "sales:metrics:members:all";
|
||||||
|
const MEMBER_KEY_PREFIX = "sales:metrics:member:";
|
||||||
|
const OPP_REVENUE_KEY_PREFIX = "sales:metrics:oppRevenue:";
|
||||||
|
const PRODUCT_FETCH_CONCURRENCY = 6;
|
||||||
|
const PRODUCT_LOOKUP_TIMEOUT_MS = 35_000;
|
||||||
|
const LOG_PREFIX = "[cache:salesMetrics]";
|
||||||
|
|
||||||
|
const log = (message: string) => {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
console.log(`${LOG_PREFIX} ${ts} ${message}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
let salesMetricsRefreshInFlight: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const memberKey = (identifier: string) =>
|
||||||
|
`${MEMBER_KEY_PREFIX}${identifier.toLowerCase()}`;
|
||||||
|
const oppRevenueKey = (cwOpportunityId: number) =>
|
||||||
|
`${OPP_REVENUE_KEY_PREFIX}${cwOpportunityId}`;
|
||||||
|
|
||||||
|
const deleteKeysByPrefix = async (prefix: string) => {
|
||||||
|
const keys = await redis.keys(`${prefix}*`);
|
||||||
|
if (keys.length === 0) return 0;
|
||||||
|
|
||||||
|
await redis.del(...keys);
|
||||||
|
return keys.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface OpportunityBreakdownEntry {
|
||||||
|
id: string;
|
||||||
|
cwId: number;
|
||||||
|
name: string;
|
||||||
|
revenue: number;
|
||||||
|
taxableRevenue: number;
|
||||||
|
nonTaxableRevenue: number;
|
||||||
|
/** Probability as a 0–100 percent value */
|
||||||
|
probability: number;
|
||||||
|
weightedRevenue: number;
|
||||||
|
closedDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberSalesMetrics {
|
||||||
|
memberIdentifier: string;
|
||||||
|
memberName: string;
|
||||||
|
generatedAt: string;
|
||||||
|
pipelineRevenue: number;
|
||||||
|
closedWonRevenueMtd: number;
|
||||||
|
closedWonRevenueYtd: number;
|
||||||
|
winCount: { mtd: number; ytd: number };
|
||||||
|
lossCount: { mtd: number; ytd: number };
|
||||||
|
avgDaysToClose: number;
|
||||||
|
openOpportunityCount: number;
|
||||||
|
wonOpportunityCount: { mtd: number; ytd: number };
|
||||||
|
lostOpportunityCount: { mtd: number; ytd: number };
|
||||||
|
closedOpportunityCount: { mtd: number; ytd: number };
|
||||||
|
weightedPipelineRevenue: number;
|
||||||
|
taxablePipelineRevenue: number;
|
||||||
|
nonTaxablePipelineRevenue: number;
|
||||||
|
avgOpenDealSize: number;
|
||||||
|
avgWonDealSize: { mtd: number; ytd: number };
|
||||||
|
winRate: { mtd: number; ytd: number };
|
||||||
|
lossRate: { mtd: number; ytd: number };
|
||||||
|
assignedOpportunityCount: number;
|
||||||
|
cacheHitCount: number;
|
||||||
|
cacheMissCount: number;
|
||||||
|
cacheHitRate: number;
|
||||||
|
opportunityBreakdown: {
|
||||||
|
pipeline: OpportunityBreakdownEntry[];
|
||||||
|
closedWonMtd: OpportunityBreakdownEntry[];
|
||||||
|
closedWonYtd: OpportunityBreakdownEntry[];
|
||||||
|
closedLostMtd: OpportunityBreakdownEntry[];
|
||||||
|
closedLostYtd: OpportunityBreakdownEntry[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesMetricsCacheEnvelope {
|
||||||
|
generatedAt: string;
|
||||||
|
activeMemberCount: number;
|
||||||
|
memberIdentifiers: string[];
|
||||||
|
members: Record<string, MemberSalesMetrics>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpportunityRevenue {
|
||||||
|
totalRevenue: number;
|
||||||
|
taxableRevenue: number;
|
||||||
|
nonTaxableRevenue: number;
|
||||||
|
cacheHit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedOpportunityRevenue {
|
||||||
|
totalRevenue: number;
|
||||||
|
taxableRevenue: number;
|
||||||
|
nonTaxableRevenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpportunityRow {
|
||||||
|
id: string;
|
||||||
|
cwOpportunityId: number;
|
||||||
|
name: string;
|
||||||
|
primarySalesRepIdentifier: string | null;
|
||||||
|
secondarySalesRepIdentifier: string | null;
|
||||||
|
statusCwId: number | null;
|
||||||
|
statusName: string | null;
|
||||||
|
closedFlag: boolean;
|
||||||
|
dateBecameLead: Date | null;
|
||||||
|
closedDate: Date | null;
|
||||||
|
probability: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshSalesOpportunityMetricsCacheOptions {
|
||||||
|
forceColdLoad?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundCurrency = (value: number) => Math.round(value * 100) / 100;
|
||||||
|
|
||||||
|
const daysBetween = (start: Date, end: Date): number => {
|
||||||
|
const msPerDay = 1000 * 60 * 60 * 24;
|
||||||
|
return Math.max(0, (end.getTime() - start.getTime()) / msPerDay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startOfMonthUtc = (input: Date): Date =>
|
||||||
|
new Date(Date.UTC(input.getUTCFullYear(), input.getUTCMonth(), 1, 0, 0, 0));
|
||||||
|
|
||||||
|
const startOfYearUtc = (input: Date): Date =>
|
||||||
|
new Date(Date.UTC(input.getUTCFullYear(), 0, 1, 0, 0, 0));
|
||||||
|
|
||||||
|
const toFinite = (value: unknown): number => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return 0;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWon = (opp: {
|
||||||
|
statusCwId: number | null;
|
||||||
|
statusName: string | null;
|
||||||
|
closedFlag: boolean;
|
||||||
|
}) => {
|
||||||
|
if (opp.statusCwId === OpportunityStatus.Won) return true;
|
||||||
|
if (opp.statusName?.toLowerCase().includes("won")) return true;
|
||||||
|
if (opp.closedFlag && opp.statusName?.toLowerCase().includes("won"))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLost = (opp: {
|
||||||
|
statusCwId: number | null;
|
||||||
|
statusName: string | null;
|
||||||
|
closedFlag: boolean;
|
||||||
|
}) => {
|
||||||
|
if (opp.statusCwId === OpportunityStatus.Lost) return true;
|
||||||
|
if (opp.statusName?.toLowerCase().includes("lost")) return true;
|
||||||
|
if (opp.closedFlag && opp.statusName?.toLowerCase().includes("lost"))
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isClosedOpportunity = (opp: {
|
||||||
|
statusCwId: number | null;
|
||||||
|
statusName: string | null;
|
||||||
|
closedFlag: boolean;
|
||||||
|
}) => {
|
||||||
|
if (opp.closedFlag) return true;
|
||||||
|
if (isWon(opp)) return true;
|
||||||
|
if (isLost(opp)) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCancellationMap = (procProducts: any[]) => {
|
||||||
|
const map = new Map<number, any>();
|
||||||
|
|
||||||
|
for (const pp of procProducts) {
|
||||||
|
const rawForecastDetailId = pp?.forecastDetailId;
|
||||||
|
const forecastDetailId =
|
||||||
|
typeof rawForecastDetailId === "number"
|
||||||
|
? rawForecastDetailId
|
||||||
|
: Number(rawForecastDetailId);
|
||||||
|
|
||||||
|
if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) {
|
||||||
|
map.set(forecastDetailId, pp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeRevenueFromProductsBlob = (
|
||||||
|
blob: any,
|
||||||
|
): Omit<OpportunityRevenue, "cacheHit"> => {
|
||||||
|
const forecastItems = Array.isArray(blob?.forecast?.forecastItems)
|
||||||
|
? blob.forecast.forecastItems
|
||||||
|
: [];
|
||||||
|
const procProducts = Array.isArray(blob?.procProducts)
|
||||||
|
? blob.procProducts
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const cancellationMap = buildCancellationMap(procProducts);
|
||||||
|
|
||||||
|
let totalRevenue = 0;
|
||||||
|
let taxableRevenue = 0;
|
||||||
|
|
||||||
|
for (const item of forecastItems) {
|
||||||
|
if (!cancellationMap.has(item?.id)) continue;
|
||||||
|
if (!item?.includeFlag) continue;
|
||||||
|
|
||||||
|
const quantity = Math.max(0, toFinite(item?.quantity));
|
||||||
|
const revenue = toFinite(item?.revenue);
|
||||||
|
|
||||||
|
const cancellation = cancellationMap.get(item.id);
|
||||||
|
const cancelledFlag = Boolean(cancellation?.cancelledFlag);
|
||||||
|
const quantityCancelled = Math.max(
|
||||||
|
0,
|
||||||
|
toFinite(cancellation?.quantityCancelled),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelledFlag && quantity > 0 && quantityCancelled >= quantity)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const ratio =
|
||||||
|
quantity > 0 ? Math.max(0, (quantity - quantityCancelled) / quantity) : 1;
|
||||||
|
const effectiveRevenue = revenue * ratio;
|
||||||
|
|
||||||
|
totalRevenue += effectiveRevenue;
|
||||||
|
if (item?.taxableFlag) taxableRevenue += effectiveRevenue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonTaxableRevenue = totalRevenue - taxableRevenue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRevenue: roundCurrency(totalRevenue),
|
||||||
|
taxableRevenue: roundCurrency(taxableRevenue),
|
||||||
|
nonTaxableRevenue: roundCurrency(nonTaxableRevenue),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeRevenueFromControllers = (
|
||||||
|
products: Array<{
|
||||||
|
includeFlag: boolean;
|
||||||
|
taxableFlag: boolean;
|
||||||
|
cancellationType: "full" | "partial" | null;
|
||||||
|
effectiveRevenue: number;
|
||||||
|
}>,
|
||||||
|
): Omit<OpportunityRevenue, "cacheHit"> => {
|
||||||
|
let totalRevenue = 0;
|
||||||
|
let taxableRevenue = 0;
|
||||||
|
|
||||||
|
for (const item of products) {
|
||||||
|
if (!item.includeFlag) continue;
|
||||||
|
if (item.cancellationType === "full") continue;
|
||||||
|
|
||||||
|
const effectiveRevenue = Math.max(0, toFinite(item.effectiveRevenue));
|
||||||
|
totalRevenue += effectiveRevenue;
|
||||||
|
if (item.taxableFlag) taxableRevenue += effectiveRevenue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nonTaxableRevenue = totalRevenue - taxableRevenue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRevenue: roundCurrency(totalRevenue),
|
||||||
|
taxableRevenue: roundCurrency(taxableRevenue),
|
||||||
|
nonTaxableRevenue: roundCurrency(nonTaxableRevenue),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readCachedOpportunityRevenue = async (
|
||||||
|
cwOpportunityId: number,
|
||||||
|
): Promise<CachedOpportunityRevenue | null> => {
|
||||||
|
const raw = await redis.get(oppRevenueKey(cwOpportunityId));
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as CachedOpportunityRevenue;
|
||||||
|
return {
|
||||||
|
totalRevenue: toFinite(parsed.totalRevenue),
|
||||||
|
taxableRevenue: toFinite(parsed.taxableRevenue),
|
||||||
|
nonTaxableRevenue: toFinite(parsed.nonTaxableRevenue),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeCachedOpportunityRevenue = async (
|
||||||
|
cwOpportunityId: number,
|
||||||
|
revenue: Omit<OpportunityRevenue, "cacheHit">,
|
||||||
|
) => {
|
||||||
|
await redis.set(
|
||||||
|
oppRevenueKey(cwOpportunityId),
|
||||||
|
JSON.stringify(revenue),
|
||||||
|
"PX",
|
||||||
|
METRICS_CACHE_TTL_MS,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveProbabilityRatio = async (opp: {
|
||||||
|
cwOpportunityId: number;
|
||||||
|
probability: number;
|
||||||
|
}): Promise<number> => {
|
||||||
|
const fromDb = normalizeProbabilityRatio(opp.probability);
|
||||||
|
if (fromDb > 0) return fromDb;
|
||||||
|
|
||||||
|
const cachedCwOpp = await getCachedOppCwData(opp.cwOpportunityId);
|
||||||
|
if (!cachedCwOpp) return 0;
|
||||||
|
|
||||||
|
const rawProbability =
|
||||||
|
cachedCwOpp?.probability?.name ?? cachedCwOpp?.probability ?? 0;
|
||||||
|
return normalizeProbabilityRatio(rawProbability);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOpportunityRevenueCacheFirst = async (
|
||||||
|
cwOpportunityId: number,
|
||||||
|
opts?: RefreshSalesOpportunityMetricsCacheOptions,
|
||||||
|
): Promise<OpportunityRevenue> => {
|
||||||
|
if (!opts?.forceColdLoad) {
|
||||||
|
const cachedRevenue = await readCachedOpportunityRevenue(cwOpportunityId);
|
||||||
|
if (cachedRevenue) {
|
||||||
|
return {
|
||||||
|
...cachedRevenue,
|
||||||
|
cacheHit: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts?.forceColdLoad) {
|
||||||
|
const cachedProducts = await getCachedProducts(cwOpportunityId);
|
||||||
|
if (cachedProducts) {
|
||||||
|
const computed = computeRevenueFromProductsBlob(cachedProducts);
|
||||||
|
await writeCachedOpportunityRevenue(cwOpportunityId, computed);
|
||||||
|
return {
|
||||||
|
...computed,
|
||||||
|
cacheHit: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const opportunity = await opportunities.fetchRecord(cwOpportunityId);
|
||||||
|
const products = await opportunity.fetchProducts({
|
||||||
|
fresh: opts?.forceColdLoad,
|
||||||
|
});
|
||||||
|
const computed = computeRevenueFromControllers(products);
|
||||||
|
await writeCachedOpportunityRevenue(cwOpportunityId, computed);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...computed,
|
||||||
|
cacheHit: false,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
totalRevenue: 0,
|
||||||
|
taxableRevenue: 0,
|
||||||
|
nonTaxableRevenue: 0,
|
||||||
|
cacheHit: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const withTimeout = async <T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<T> => {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<T>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error("Timeout")), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mapWithConcurrency<T, R>(
|
||||||
|
items: T[],
|
||||||
|
concurrency: number,
|
||||||
|
mapper: (item: T) => Promise<R>,
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results: R[] = new Array(items.length);
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const worker = async () => {
|
||||||
|
while (true) {
|
||||||
|
const current = index;
|
||||||
|
index += 1;
|
||||||
|
if (current >= items.length) return;
|
||||||
|
results[current] = await mapper(items[current]!);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const workers = Array.from(
|
||||||
|
{ length: Math.min(concurrency, items.length) },
|
||||||
|
() => worker(),
|
||||||
|
);
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEmptyMetrics = (
|
||||||
|
memberIdentifier: string,
|
||||||
|
memberName: string,
|
||||||
|
generatedAt: string,
|
||||||
|
): MemberSalesMetrics => ({
|
||||||
|
memberIdentifier,
|
||||||
|
memberName,
|
||||||
|
generatedAt,
|
||||||
|
pipelineRevenue: 0,
|
||||||
|
closedWonRevenueMtd: 0,
|
||||||
|
closedWonRevenueYtd: 0,
|
||||||
|
winCount: { mtd: 0, ytd: 0 },
|
||||||
|
lossCount: { mtd: 0, ytd: 0 },
|
||||||
|
avgDaysToClose: 0,
|
||||||
|
openOpportunityCount: 0,
|
||||||
|
wonOpportunityCount: { mtd: 0, ytd: 0 },
|
||||||
|
lostOpportunityCount: { mtd: 0, ytd: 0 },
|
||||||
|
closedOpportunityCount: { mtd: 0, ytd: 0 },
|
||||||
|
weightedPipelineRevenue: 0,
|
||||||
|
taxablePipelineRevenue: 0,
|
||||||
|
nonTaxablePipelineRevenue: 0,
|
||||||
|
avgOpenDealSize: 0,
|
||||||
|
avgWonDealSize: { mtd: 0, ytd: 0 },
|
||||||
|
winRate: { mtd: 0, ytd: 0 },
|
||||||
|
lossRate: { mtd: 0, ytd: 0 },
|
||||||
|
assignedOpportunityCount: 0,
|
||||||
|
cacheHitCount: 0,
|
||||||
|
cacheMissCount: 0,
|
||||||
|
cacheHitRate: 0,
|
||||||
|
opportunityBreakdown: {
|
||||||
|
pipeline: [],
|
||||||
|
closedWonMtd: [],
|
||||||
|
closedWonYtd: [],
|
||||||
|
closedLostMtd: [],
|
||||||
|
closedLostYtd: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function refreshSalesOpportunityMetricsCache(
|
||||||
|
opts?: RefreshSalesOpportunityMetricsCacheOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
if (salesMetricsRefreshInFlight) {
|
||||||
|
log(
|
||||||
|
"refresh requested while previous run is still in-flight; reusing existing run",
|
||||||
|
);
|
||||||
|
return salesMetricsRefreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
salesMetricsRefreshInFlight = (async () => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const forceColdLoad = opts?.forceColdLoad === true;
|
||||||
|
log(`refresh started${forceColdLoad ? " | mode=cold" : " | mode=warm"}`);
|
||||||
|
|
||||||
|
if (forceColdLoad) {
|
||||||
|
const [deletedMemberKeys, deletedRevenueKeys] = await Promise.all([
|
||||||
|
deleteKeysByPrefix(MEMBER_KEY_PREFIX),
|
||||||
|
deleteKeysByPrefix(OPP_REVENUE_KEY_PREFIX),
|
||||||
|
redis.del(ALL_MEMBERS_KEY),
|
||||||
|
]);
|
||||||
|
|
||||||
|
log(
|
||||||
|
`cold-load reset completed: memberKeysCleared=${deletedMemberKeys} oppRevenueKeysCleared=${deletedRevenueKeys}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const generatedAt = now.toISOString();
|
||||||
|
const monthStart = startOfMonthUtc(now);
|
||||||
|
const yearStart = startOfYearUtc(now);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeMembers = await prisma.cwMember.findMany({
|
||||||
|
where: { inactiveFlag: false },
|
||||||
|
select: {
|
||||||
|
identifier: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberIdentifiers = activeMembers.map(
|
||||||
|
(member) => member.identifier,
|
||||||
|
);
|
||||||
|
log(`members fetched: activeMembers=${memberIdentifiers.length}`);
|
||||||
|
|
||||||
|
const opportunityRows: OpportunityRow[] =
|
||||||
|
await prisma.opportunity.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ primarySalesRepIdentifier: { in: memberIdentifiers } },
|
||||||
|
{ secondarySalesRepIdentifier: { in: memberIdentifiers } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ dateBecameLead: { gte: yearStart } },
|
||||||
|
{
|
||||||
|
OR: [{ closedFlag: false }, { closedDate: { gte: yearStart } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
cwOpportunityId: true,
|
||||||
|
name: true,
|
||||||
|
primarySalesRepIdentifier: true,
|
||||||
|
secondarySalesRepIdentifier: true,
|
||||||
|
statusCwId: true,
|
||||||
|
statusName: true,
|
||||||
|
closedFlag: true,
|
||||||
|
dateBecameLead: true,
|
||||||
|
closedDate: true,
|
||||||
|
probability: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
log(
|
||||||
|
`opportunities fetched: assignedOpportunityRows=${opportunityRows.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
events.emit("cache:salesMetrics:refresh:started", {
|
||||||
|
activeMemberCount: memberIdentifiers.length,
|
||||||
|
opportunityCount: opportunityRows.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (memberIdentifiers.length === 0) {
|
||||||
|
const emptyEnvelope: SalesMetricsCacheEnvelope = {
|
||||||
|
generatedAt,
|
||||||
|
activeMemberCount: 0,
|
||||||
|
memberIdentifiers: [],
|
||||||
|
members: {},
|
||||||
|
};
|
||||||
|
await redis.set(
|
||||||
|
ALL_MEMBERS_KEY,
|
||||||
|
JSON.stringify(emptyEnvelope),
|
||||||
|
"PX",
|
||||||
|
METRICS_CACHE_TTL_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
events.emit("cache:salesMetrics:refresh:completed", {
|
||||||
|
activeMemberCount: 0,
|
||||||
|
opportunityCount: 0,
|
||||||
|
memberMetricsWritten: 0,
|
||||||
|
cacheHitCount: 0,
|
||||||
|
cacheMissCount: 0,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
log("no active members found; wrote empty cache envelope");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revenuePhaseStartedAt = Date.now();
|
||||||
|
let revenueLookupProcessed = 0;
|
||||||
|
let revenueLookupTimeouts = 0;
|
||||||
|
let revenueLookupFailures = 0;
|
||||||
|
let revenueLookupCacheHits = 0;
|
||||||
|
let revenueLookupCacheMisses = 0;
|
||||||
|
|
||||||
|
log(
|
||||||
|
`revenue lookup phase started: concurrency=${PRODUCT_FETCH_CONCURRENCY} timeoutMs=${PRODUCT_LOOKUP_TIMEOUT_MS}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const revenueRows = await mapWithConcurrency(
|
||||||
|
opportunityRows,
|
||||||
|
PRODUCT_FETCH_CONCURRENCY,
|
||||||
|
async (opp) => {
|
||||||
|
const [revenue, probabilityRatio] = await Promise.all([
|
||||||
|
withTimeout(
|
||||||
|
getOpportunityRevenueCacheFirst(opp.cwOpportunityId, {
|
||||||
|
forceColdLoad,
|
||||||
|
}),
|
||||||
|
PRODUCT_LOOKUP_TIMEOUT_MS,
|
||||||
|
).catch((err: any) => {
|
||||||
|
if (err?.message === "Timeout") {
|
||||||
|
revenueLookupTimeouts += 1;
|
||||||
|
}
|
||||||
|
if (err?.message !== "Timeout") {
|
||||||
|
revenueLookupFailures += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRevenue: 0,
|
||||||
|
taxableRevenue: 0,
|
||||||
|
nonTaxableRevenue: 0,
|
||||||
|
cacheHit: false,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
resolveProbabilityRatio(opp),
|
||||||
|
]);
|
||||||
|
|
||||||
|
revenueLookupProcessed += 1;
|
||||||
|
if (revenue.cacheHit) revenueLookupCacheHits += 1;
|
||||||
|
if (!revenue.cacheHit) revenueLookupCacheMisses += 1;
|
||||||
|
|
||||||
|
if (revenueLookupProcessed % 100 === 0) {
|
||||||
|
log(
|
||||||
|
`revenue lookup progress: processed=${revenueLookupProcessed}/${opportunityRows.length} cacheHits=${revenueLookupCacheHits} cacheMisses=${revenueLookupCacheMisses} timeouts=${revenueLookupTimeouts} failures=${revenueLookupFailures}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { oppId: opp.id, revenue, probabilityRatio };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
log(
|
||||||
|
`revenue lookup phase completed in ${Date.now() - revenuePhaseStartedAt}ms: processed=${revenueLookupProcessed}/${opportunityRows.length} cacheHits=${revenueLookupCacheHits} cacheMisses=${revenueLookupCacheMisses} timeouts=${revenueLookupTimeouts} failures=${revenueLookupFailures}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const revenueByOppId = new Map(
|
||||||
|
revenueRows.map((row) => [row.oppId, row.revenue]),
|
||||||
|
);
|
||||||
|
const probabilityByOppId = new Map(
|
||||||
|
revenueRows.map((row) => [row.oppId, row.probabilityRatio]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const opportunitiesByMember = new Map<string, OpportunityRow[]>();
|
||||||
|
for (const identifier of memberIdentifiers) {
|
||||||
|
opportunitiesByMember.set(identifier, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const opp of opportunityRows) {
|
||||||
|
const assigned = new Set<string>();
|
||||||
|
if (opp.primarySalesRepIdentifier)
|
||||||
|
assigned.add(opp.primarySalesRepIdentifier);
|
||||||
|
if (opp.secondarySalesRepIdentifier)
|
||||||
|
assigned.add(opp.secondarySalesRepIdentifier);
|
||||||
|
|
||||||
|
for (const identifier of assigned) {
|
||||||
|
const bucket = opportunitiesByMember.get(identifier);
|
||||||
|
if (!bucket) continue;
|
||||||
|
bucket.push(opp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const members: Record<string, MemberSalesMetrics> = {};
|
||||||
|
log("member aggregation phase started");
|
||||||
|
|
||||||
|
for (const member of activeMembers) {
|
||||||
|
const identifier = member.identifier;
|
||||||
|
const assigned = opportunitiesByMember.get(identifier) ?? [];
|
||||||
|
const metric = buildEmptyMetrics(
|
||||||
|
identifier,
|
||||||
|
`${member.firstName} ${member.lastName}`.trim() || identifier,
|
||||||
|
generatedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
let wonDaysSumYtd = 0;
|
||||||
|
|
||||||
|
for (const opp of assigned) {
|
||||||
|
const revenue = revenueByOppId.get(opp.id) ?? {
|
||||||
|
totalRevenue: 0,
|
||||||
|
taxableRevenue: 0,
|
||||||
|
nonTaxableRevenue: 0,
|
||||||
|
cacheHit: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
metric.cacheHitCount += revenue.cacheHit ? 1 : 0;
|
||||||
|
metric.cacheMissCount += revenue.cacheHit ? 0 : 1;
|
||||||
|
|
||||||
|
const won = isWon(opp);
|
||||||
|
const lost = isLost(opp);
|
||||||
|
const closed = isClosedOpportunity(opp);
|
||||||
|
const probabilityRatio = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(1, toFinite(probabilityByOppId.get(opp.id))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const breakdownEntry: OpportunityBreakdownEntry = {
|
||||||
|
id: opp.id,
|
||||||
|
cwId: opp.cwOpportunityId,
|
||||||
|
name: opp.name,
|
||||||
|
revenue: revenue.totalRevenue,
|
||||||
|
taxableRevenue: revenue.taxableRevenue,
|
||||||
|
nonTaxableRevenue: revenue.nonTaxableRevenue,
|
||||||
|
probability: roundCurrency(probabilityRatio * 100),
|
||||||
|
weightedRevenue: roundCurrency(
|
||||||
|
revenue.totalRevenue * probabilityRatio,
|
||||||
|
),
|
||||||
|
closedDate: opp.closedDate?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!closed) {
|
||||||
|
metric.openOpportunityCount += 1;
|
||||||
|
metric.pipelineRevenue += revenue.totalRevenue;
|
||||||
|
metric.taxablePipelineRevenue += revenue.taxableRevenue;
|
||||||
|
metric.nonTaxablePipelineRevenue += revenue.nonTaxableRevenue;
|
||||||
|
metric.weightedPipelineRevenue +=
|
||||||
|
revenue.totalRevenue * probabilityRatio;
|
||||||
|
metric.opportunityBreakdown.pipeline.push(breakdownEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const closedDate = opp.closedDate;
|
||||||
|
if (!closedDate) continue;
|
||||||
|
|
||||||
|
const isMtd = closedDate >= monthStart;
|
||||||
|
const isYtd = closedDate >= yearStart;
|
||||||
|
|
||||||
|
if (won) {
|
||||||
|
if (isMtd) {
|
||||||
|
metric.winCount.mtd += 1;
|
||||||
|
metric.wonOpportunityCount.mtd += 1;
|
||||||
|
metric.closedOpportunityCount.mtd += 1;
|
||||||
|
metric.closedWonRevenueMtd += revenue.totalRevenue;
|
||||||
|
metric.opportunityBreakdown.closedWonMtd.push(breakdownEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isYtd) {
|
||||||
|
metric.winCount.ytd += 1;
|
||||||
|
metric.wonOpportunityCount.ytd += 1;
|
||||||
|
metric.closedOpportunityCount.ytd += 1;
|
||||||
|
metric.closedWonRevenueYtd += revenue.totalRevenue;
|
||||||
|
wonDaysSumYtd += daysBetween(
|
||||||
|
opp.dateBecameLead ?? closedDate,
|
||||||
|
closedDate,
|
||||||
|
);
|
||||||
|
metric.opportunityBreakdown.closedWonYtd.push(breakdownEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lost) continue;
|
||||||
|
|
||||||
|
if (isMtd) {
|
||||||
|
metric.lossCount.mtd += 1;
|
||||||
|
metric.lostOpportunityCount.mtd += 1;
|
||||||
|
metric.closedOpportunityCount.mtd += 1;
|
||||||
|
metric.opportunityBreakdown.closedLostMtd.push(breakdownEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isYtd) continue;
|
||||||
|
|
||||||
|
metric.lossCount.ytd += 1;
|
||||||
|
metric.lostOpportunityCount.ytd += 1;
|
||||||
|
metric.closedOpportunityCount.ytd += 1;
|
||||||
|
metric.opportunityBreakdown.closedLostYtd.push(breakdownEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
metric.assignedOpportunityCount = assigned.length;
|
||||||
|
|
||||||
|
metric.avgDaysToClose =
|
||||||
|
metric.winCount.ytd > 0 ? wonDaysSumYtd / metric.winCount.ytd : 0;
|
||||||
|
|
||||||
|
metric.avgOpenDealSize =
|
||||||
|
metric.openOpportunityCount > 0
|
||||||
|
? metric.pipelineRevenue / metric.openOpportunityCount
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
metric.avgWonDealSize.mtd =
|
||||||
|
metric.winCount.mtd > 0
|
||||||
|
? metric.closedWonRevenueMtd / metric.winCount.mtd
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
metric.avgWonDealSize.ytd =
|
||||||
|
metric.winCount.ytd > 0
|
||||||
|
? metric.closedWonRevenueYtd / metric.winCount.ytd
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const closedMtd = metric.winCount.mtd + metric.lossCount.mtd;
|
||||||
|
const closedYtd = metric.winCount.ytd + metric.lossCount.ytd;
|
||||||
|
|
||||||
|
metric.winRate.mtd =
|
||||||
|
closedMtd > 0 ? metric.winCount.mtd / closedMtd : 0;
|
||||||
|
metric.winRate.ytd =
|
||||||
|
closedYtd > 0 ? metric.winCount.ytd / closedYtd : 0;
|
||||||
|
metric.lossRate.mtd =
|
||||||
|
closedMtd > 0 ? metric.lossCount.mtd / closedMtd : 0;
|
||||||
|
metric.lossRate.ytd =
|
||||||
|
closedYtd > 0 ? metric.lossCount.ytd / closedYtd : 0;
|
||||||
|
|
||||||
|
const totalLookups = metric.cacheHitCount + metric.cacheMissCount;
|
||||||
|
metric.cacheHitRate =
|
||||||
|
totalLookups > 0 ? metric.cacheHitCount / totalLookups : 0;
|
||||||
|
|
||||||
|
metric.pipelineRevenue = roundCurrency(metric.pipelineRevenue);
|
||||||
|
metric.closedWonRevenueMtd = roundCurrency(metric.closedWonRevenueMtd);
|
||||||
|
metric.closedWonRevenueYtd = roundCurrency(metric.closedWonRevenueYtd);
|
||||||
|
metric.weightedPipelineRevenue = roundCurrency(
|
||||||
|
metric.weightedPipelineRevenue,
|
||||||
|
);
|
||||||
|
metric.taxablePipelineRevenue = roundCurrency(
|
||||||
|
metric.taxablePipelineRevenue,
|
||||||
|
);
|
||||||
|
metric.nonTaxablePipelineRevenue = roundCurrency(
|
||||||
|
metric.nonTaxablePipelineRevenue,
|
||||||
|
);
|
||||||
|
metric.avgDaysToClose = roundCurrency(metric.avgDaysToClose);
|
||||||
|
metric.avgOpenDealSize = roundCurrency(metric.avgOpenDealSize);
|
||||||
|
metric.avgWonDealSize.mtd = roundCurrency(metric.avgWonDealSize.mtd);
|
||||||
|
metric.avgWonDealSize.ytd = roundCurrency(metric.avgWonDealSize.ytd);
|
||||||
|
metric.winRate.mtd = roundCurrency(metric.winRate.mtd);
|
||||||
|
metric.winRate.ytd = roundCurrency(metric.winRate.ytd);
|
||||||
|
metric.lossRate.mtd = roundCurrency(metric.lossRate.mtd);
|
||||||
|
metric.lossRate.ytd = roundCurrency(metric.lossRate.ytd);
|
||||||
|
metric.cacheHitRate = roundCurrency(metric.cacheHitRate);
|
||||||
|
|
||||||
|
members[identifier] = metric;
|
||||||
|
|
||||||
|
if (Object.keys(members).length % 25 === 0) {
|
||||||
|
log(
|
||||||
|
`member aggregation progress: aggregated=${Object.keys(members).length}/${activeMembers.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
`member aggregation completed: totalMembers=${Object.keys(members).length}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const envelope: SalesMetricsCacheEnvelope = {
|
||||||
|
generatedAt,
|
||||||
|
activeMemberCount: memberIdentifiers.length,
|
||||||
|
memberIdentifiers,
|
||||||
|
members,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pipeline = redis.pipeline();
|
||||||
|
log("redis write phase started");
|
||||||
|
pipeline.set(
|
||||||
|
ALL_MEMBERS_KEY,
|
||||||
|
JSON.stringify(envelope),
|
||||||
|
"PX",
|
||||||
|
METRICS_CACHE_TTL_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const identifier of Object.keys(members)) {
|
||||||
|
pipeline.set(
|
||||||
|
memberKey(identifier),
|
||||||
|
JSON.stringify(members[identifier]),
|
||||||
|
"PX",
|
||||||
|
METRICS_CACHE_TTL_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pipeline.exec();
|
||||||
|
log("redis write phase completed");
|
||||||
|
|
||||||
|
const cacheHitCount = Object.values(members).reduce(
|
||||||
|
(sum, metric) => sum + metric.cacheHitCount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const cacheMissCount = Object.values(members).reduce(
|
||||||
|
(sum, metric) => sum + metric.cacheMissCount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
events.emit("cache:salesMetrics:refresh:completed", {
|
||||||
|
activeMemberCount: memberIdentifiers.length,
|
||||||
|
opportunityCount: opportunityRows.length,
|
||||||
|
memberMetricsWritten: Object.keys(members).length,
|
||||||
|
cacheHitCount,
|
||||||
|
cacheMissCount,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
log(
|
||||||
|
`completed in ${Date.now() - startedAt}ms | activeMembers=${memberIdentifiers.length} opportunities=${opportunityRows.length} memberMetrics=${Object.keys(members).length} cacheHits=${cacheHitCount} cacheMisses=${cacheMissCount}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
log(`refresh failed in ${Date.now() - startedAt}ms: ${String(error)}`);
|
||||||
|
events.emit("cache:salesMetrics:refresh:error", {
|
||||||
|
error,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
salesMetricsRefreshInFlight = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return salesMetricsRefreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSalesOpportunityMetricsAll(): Promise<SalesMetricsCacheEnvelope | null> {
|
||||||
|
const raw = await redis.get(ALL_MEMBERS_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as SalesMetricsCacheEnvelope;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSalesOpportunityMetricsForMember(
|
||||||
|
identifier: string,
|
||||||
|
): Promise<MemberSalesMetrics | null> {
|
||||||
|
const normalized = identifier.trim().toLowerCase();
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
const raw = await redis.get(memberKey(normalized));
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as MemberSalesMetrics;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = await getSalesOpportunityMetricsAll();
|
||||||
|
if (!all) return null;
|
||||||
|
return all.members[normalized] ?? null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CWOpportunity } from "./opportunity.types";
|
import { CWOpportunity } from "./opportunity.types";
|
||||||
|
import { normalizeProbabilityPercent } from "../../sales-utils/normalizeProbability";
|
||||||
|
|
||||||
export type ProcessedOpportunity = ReturnType<
|
export type ProcessedOpportunity = ReturnType<
|
||||||
typeof processOpportunityResponse
|
typeof processOpportunityResponse
|
||||||
@@ -14,7 +15,7 @@ export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
|
|||||||
expectedCloseDate: opportunity.expectedCloseDate,
|
expectedCloseDate: opportunity.expectedCloseDate,
|
||||||
closedDate: opportunity.closedDate,
|
closedDate: opportunity.closedDate,
|
||||||
closedFlag: opportunity.closedFlag,
|
closedFlag: opportunity.closedFlag,
|
||||||
probability: Number(opportunity.probability?.name) || 0,
|
probability: normalizeProbabilityPercent(opportunity.probability?.name),
|
||||||
type: opportunity.type
|
type: opportunity.type
|
||||||
? { id: opportunity.type.id, name: opportunity.type.name }
|
? { id: opportunity.type.id, name: opportunity.type.name }
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { prisma } from "../../../constants";
|
|||||||
import { events } from "../../globalEvents";
|
import { events } from "../../globalEvents";
|
||||||
import { opportunityCw } from "./opportunities";
|
import { opportunityCw } from "./opportunities";
|
||||||
import { OpportunityController } from "../../../controllers/OpportunityController";
|
import { OpportunityController } from "../../../controllers/OpportunityController";
|
||||||
|
import { invalidateAllOpportunityCaches } from "../../cache/opportunityCache";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh Opportunities
|
* Refresh Opportunities
|
||||||
@@ -21,11 +22,14 @@ export const refreshOpportunities = async () => {
|
|||||||
|
|
||||||
// 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated
|
// 2. Fetch all DB items with their cwOpportunityId and cwLastUpdated
|
||||||
const dbItems = await prisma.opportunity.findMany({
|
const dbItems = await prisma.opportunity.findMany({
|
||||||
select: { cwOpportunityId: true, cwLastUpdated: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
cwOpportunityId: true,
|
||||||
|
cwLastUpdated: true,
|
||||||
|
cwDateEntered: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const dbMap = new Map(
|
const dbMap = new Map(dbItems.map((item) => [item.cwOpportunityId, item]));
|
||||||
dbItems.map((item) => [item.cwOpportunityId, item.cwLastUpdated]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Determine stale / new IDs
|
// 3. Determine stale / new IDs
|
||||||
const staleIds: number[] = [];
|
const staleIds: number[] = [];
|
||||||
@@ -34,18 +38,48 @@ export const refreshOpportunities = async () => {
|
|||||||
const cwLastUpdated = summary._info?.lastUpdated
|
const cwLastUpdated = summary._info?.lastUpdated
|
||||||
? new Date(summary._info.lastUpdated)
|
? new Date(summary._info.lastUpdated)
|
||||||
: null;
|
: null;
|
||||||
const dbLastUpdated = dbMap.get(cwId) ?? null;
|
const dbItem = dbMap.get(cwId) ?? null;
|
||||||
|
const dbLastUpdated = dbItem?.cwLastUpdated ?? null;
|
||||||
|
|
||||||
if (!dbLastUpdated || (cwLastUpdated && cwLastUpdated > dbLastUpdated)) {
|
// Treat as stale if never synced, CW has newer data, or cwDateEntered is missing (backfill)
|
||||||
|
if (
|
||||||
|
!dbLastUpdated ||
|
||||||
|
(cwLastUpdated && cwLastUpdated > dbLastUpdated) ||
|
||||||
|
!dbItem?.cwDateEntered
|
||||||
|
) {
|
||||||
staleIds.push(cwId);
|
staleIds.push(cwId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3b. Reconcile — find local records that no longer exist in CW
|
||||||
|
const orphanedItems = dbItems.filter(
|
||||||
|
(item) => !cwSummaries.has(item.cwOpportunityId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orphanedItems.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[refreshOpportunities] Reconciling ${orphanedItems.length} orphaned local record(s) not found in CW`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
orphanedItems.map(async (item) => {
|
||||||
|
await prisma.opportunity.delete({ where: { id: item.id } });
|
||||||
|
await invalidateAllOpportunityCaches(item.cwOpportunityId);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
events.emit("cw:opportunities:refresh:reconciled", {
|
||||||
|
orphanedCount: orphanedItems.length,
|
||||||
|
removedCwIds: orphanedItems.map((i) => i.cwOpportunityId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (staleIds.length === 0) {
|
if (staleIds.length === 0) {
|
||||||
events.emit("cw:opportunities:refresh:skipped", {
|
events.emit("cw:opportunities:refresh:skipped", {
|
||||||
totalCw: cwSummaries.size,
|
totalCw: cwSummaries.size,
|
||||||
totalDb: dbItems.length,
|
totalDb: dbItems.length,
|
||||||
staleCount: 0,
|
staleCount: 0,
|
||||||
|
orphanedCount: orphanedItems.length,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -106,5 +140,6 @@ export const refreshOpportunities = async () => {
|
|||||||
totalDb: dbItems.length,
|
totalDb: dbItems.length,
|
||||||
staleCount: staleIds.length,
|
staleCount: staleIds.length,
|
||||||
itemsUpdated: updatedCount,
|
itemsUpdated: updatedCount,
|
||||||
|
orphanedCount: orphanedItems.length,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -171,11 +171,17 @@ interface EventTypes {
|
|||||||
totalDb: number;
|
totalDb: number;
|
||||||
staleCount: number;
|
staleCount: number;
|
||||||
itemsUpdated: number;
|
itemsUpdated: number;
|
||||||
|
orphanedCount: number;
|
||||||
|
}) => void;
|
||||||
|
"cw:opportunities:refresh:reconciled": (data: {
|
||||||
|
orphanedCount: number;
|
||||||
|
removedCwIds: number[];
|
||||||
}) => void;
|
}) => void;
|
||||||
"cw:opportunities:refresh:skipped": (data: {
|
"cw:opportunities:refresh:skipped": (data: {
|
||||||
totalCw: number;
|
totalCw: number;
|
||||||
totalDb: number;
|
totalDb: number;
|
||||||
staleCount: number;
|
staleCount: number;
|
||||||
|
orphanedCount: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
// Cache Events
|
// Cache Events
|
||||||
@@ -194,6 +200,24 @@ interface EventTypes {
|
|||||||
}) => void;
|
}) => void;
|
||||||
"cache:opportunities:refresh:error": (data: { error: unknown }) => void;
|
"cache:opportunities:refresh:error": (data: { error: unknown }) => void;
|
||||||
|
|
||||||
|
// Sales Metrics Cache Events
|
||||||
|
"cache:salesMetrics:refresh:started": (data: {
|
||||||
|
activeMemberCount: number;
|
||||||
|
opportunityCount: number;
|
||||||
|
}) => void;
|
||||||
|
"cache:salesMetrics:refresh:completed": (data: {
|
||||||
|
activeMemberCount: number;
|
||||||
|
opportunityCount: number;
|
||||||
|
memberMetricsWritten: number;
|
||||||
|
cacheHitCount: number;
|
||||||
|
cacheMissCount: number;
|
||||||
|
durationMs: number;
|
||||||
|
}) => void;
|
||||||
|
"cache:salesMetrics:refresh:error": (data: {
|
||||||
|
error: unknown;
|
||||||
|
durationMs: number;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
// ConnectWise User Defined Fields Events
|
// ConnectWise User Defined Fields Events
|
||||||
"cw:udf:refresh:started": () => void;
|
"cw:udf:refresh:started": () => void;
|
||||||
"cw:udf:refresh:completed": (data: { count: number }) => void;
|
"cw:udf:refresh:completed": (data: { count: number }) => void;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface QuoteData {
|
|||||||
contact: CustomerContact;
|
contact: CustomerContact;
|
||||||
quote: QuoteDetails;
|
quote: QuoteDetails;
|
||||||
lineItems: QuoteLineItem[];
|
lineItems: QuoteLineItem[];
|
||||||
|
taxableSubtotal?: number;
|
||||||
tax: TaxConfig;
|
tax: TaxConfig;
|
||||||
salesRep?: SalesRepInfo;
|
salesRep?: SalesRepInfo;
|
||||||
quoteNarrative?: string;
|
quoteNarrative?: string;
|
||||||
@@ -158,7 +159,8 @@ export async function generateQuote(
|
|||||||
(sum, item) => sum + item.qty * item.unitPrice,
|
(sum, item) => sum + item.qty * item.unitPrice,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const taxAmount = subTotal * data.tax.rate;
|
const taxableSubTotal = Math.max(0, data.taxableSubtotal ?? subTotal);
|
||||||
|
const taxAmount = taxableSubTotal * data.tax.rate;
|
||||||
const total = subTotal + taxAmount;
|
const total = subTotal + taxAmount;
|
||||||
const logoDataUrl = loadLogoDataUrl(logoPath);
|
const logoDataUrl = loadLogoDataUrl(logoPath);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
export interface SalesTaxAddressInput {
|
||||||
|
line1?: string | null;
|
||||||
|
line2?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalJurisdiction {
|
||||||
|
city: string;
|
||||||
|
local_rate: number;
|
||||||
|
combined_rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateTaxRecord {
|
||||||
|
state: string;
|
||||||
|
abbreviation: string;
|
||||||
|
state_rate: number;
|
||||||
|
avg_local_rate: number;
|
||||||
|
avg_combined_rate: number;
|
||||||
|
has_local_tax: boolean;
|
||||||
|
local_jurisdictions?: LocalJurisdiction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxDataPath = new URL("./salesTaxRates.json", import.meta.url);
|
||||||
|
|
||||||
|
const parseTaxData = (): StateTaxRecord[] => {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(taxDataPath, "utf-8");
|
||||||
|
const parsed = JSON.parse(raw) as StateTaxRecord[];
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SALES_TAX_DATA = parseTaxData();
|
||||||
|
|
||||||
|
const normalizeToken = (value: string | null | undefined): string | null => {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const normalized = value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!normalized) return null;
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeState = (state: string | null | undefined): string | null => {
|
||||||
|
const normalized = normalizeToken(state);
|
||||||
|
if (!normalized) return null;
|
||||||
|
|
||||||
|
const directCode = normalized.toUpperCase();
|
||||||
|
if (directCode.length === 2) return directCode;
|
||||||
|
|
||||||
|
const match = SALES_TAX_DATA.find(
|
||||||
|
(record) => normalizeToken(record.state) === normalized,
|
||||||
|
);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
return match.abbreviation.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute expected sales tax rate for an address.
|
||||||
|
* Returns a decimal tax rate (e.g. 0.06 for 6%).
|
||||||
|
*/
|
||||||
|
export const getExpectedSalesTaxRate = (
|
||||||
|
address: SalesTaxAddressInput | null | undefined,
|
||||||
|
): number => {
|
||||||
|
const state = normalizeState(address?.state);
|
||||||
|
if (!state) return 0;
|
||||||
|
|
||||||
|
// Business rule: Tennessee remains explicitly hard-coded.
|
||||||
|
if (state === "TN") return 0.0975;
|
||||||
|
|
||||||
|
const stateRecord = SALES_TAX_DATA.find(
|
||||||
|
(record) => record.abbreviation.toUpperCase() === state,
|
||||||
|
);
|
||||||
|
if (!stateRecord) return 0;
|
||||||
|
|
||||||
|
const city = normalizeToken(address?.city);
|
||||||
|
const cityMatch = stateRecord.local_jurisdictions?.find(
|
||||||
|
(jurisdiction) => normalizeToken(jurisdiction.city) === city,
|
||||||
|
);
|
||||||
|
if (cityMatch) return cityMatch.combined_rate;
|
||||||
|
|
||||||
|
return stateRecord.avg_combined_rate;
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
|
Math.min(max, Math.max(min, value));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a probability-like input to a percent scale (0..100).
|
||||||
|
* Accepts values like "70", "70%", 70, or 0.7.
|
||||||
|
*/
|
||||||
|
export const normalizeProbabilityPercent = (value: unknown): number => {
|
||||||
|
const raw =
|
||||||
|
typeof value === "string"
|
||||||
|
? Number.parseFloat(value.replace(/%/g, "").trim())
|
||||||
|
: Number(value);
|
||||||
|
|
||||||
|
if (!Number.isFinite(raw)) return 0;
|
||||||
|
|
||||||
|
const scaled = raw <= 1 ? raw * 100 : raw;
|
||||||
|
return clamp(scaled, 0, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a probability-like input to a ratio scale (0..1).
|
||||||
|
*/
|
||||||
|
export const normalizeProbabilityRatio = (value: unknown): number =>
|
||||||
|
normalizeProbabilityPercent(value) / 100;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -423,6 +423,33 @@ export const PERMISSION_NODES = {
|
|||||||
"src/api/sales/fetchOpportunityTypes.ts",
|
"src/api/sales/fetchOpportunityTypes.ts",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.fetch.@me",
|
||||||
|
description:
|
||||||
|
"View the personal sales dashboard showing opportunities assigned to the current user (UI-only gate)",
|
||||||
|
usedIn: ["UI-only (client-side gate)"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.fetch.all",
|
||||||
|
description:
|
||||||
|
"View all opportunities across all users (All Opportunities tab and View All button in the sales dashboard UI)",
|
||||||
|
usedIn: ["UI-only (client-side gate)"],
|
||||||
|
dependencies: ["sales.opportunity.fetch.many"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.metrics.all",
|
||||||
|
description:
|
||||||
|
"Allow `scope=all` on sales opportunity metrics endpoint to read cached metrics for all active members",
|
||||||
|
usedIn: ["src/api/sales/opportunities/metrics.ts"],
|
||||||
|
dependencies: ["sales.opportunity.fetch.many"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: "sales.opportunity.metrics.identifier.override",
|
||||||
|
description:
|
||||||
|
"Allow `identifier=<cwIdentifier>` override on sales opportunity metrics endpoint for querying another member",
|
||||||
|
usedIn: ["src/api/sales/opportunities/metrics.ts"],
|
||||||
|
dependencies: ["sales.opportunity.fetch.many"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
node: "sales.opportunity.refresh",
|
node: "sales.opportunity.refresh",
|
||||||
description: "Refresh a single opportunity from ConnectWise",
|
description: "Refresh a single opportunity from ConnectWise",
|
||||||
@@ -645,6 +672,12 @@ export const PERMISSION_NODES = {
|
|||||||
usedIn: ["src/api/sales/opportunities/[id]/workflow/dispatch.ts"],
|
usedIn: ["src/api/sales/opportunities/[id]/workflow/dispatch.ts"],
|
||||||
dependencies: ["sales.opportunity.fetch"],
|
dependencies: ["sales.opportunity.fetch"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
node: "sales.isRepresentative",
|
||||||
|
description:
|
||||||
|
"Designates the user as a sales representative; used for reporting and filtering purposes.",
|
||||||
|
usedIn: [],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,27 @@ export const QUOTE_STATUSES: QuoteStatus[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
//
|
||||||
|
// READY TO SEND
|
||||||
|
//
|
||||||
|
{
|
||||||
|
id: 63,
|
||||||
|
name: "Ready to Send",
|
||||||
|
wonFlag: false,
|
||||||
|
lostFlag: false,
|
||||||
|
closedFlag: false,
|
||||||
|
inactiveFlag: false,
|
||||||
|
defaultFlag: false,
|
||||||
|
enteredBy: "jroberts",
|
||||||
|
dateEntered: "2026-03-16T02:24:10Z",
|
||||||
|
_info: {
|
||||||
|
lastUpdated: "2026-03-16T02:24:10Z",
|
||||||
|
updatedBy: "jroberts",
|
||||||
|
},
|
||||||
|
connectWiseId: "f10c4e36-df20-f111-b2ee-000c29c55070",
|
||||||
|
optimaEquivalency: [],
|
||||||
|
},
|
||||||
|
|
||||||
//
|
//
|
||||||
// PENDING SENT
|
// PENDING SENT
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
* | QuoteSent | 43 | 03. Quote Sent |
|
* | QuoteSent | 43 | 03. Quote Sent |
|
||||||
* | ConfirmedQuote | 57 | 04. Confirmed Quote |
|
* | ConfirmedQuote | 57 | 04. Confirmed Quote |
|
||||||
* | Active | 58 | 05. Active |
|
* | Active | 58 | 05. Active |
|
||||||
* | PendingSent | 60 | Pending Sent |
|
* | ReadyToSend | 63 | Ready to Send |
|
||||||
|
* | PendingSent | 60 | Pending Sent (Deprecated) |
|
||||||
* | PendingRevision | 61 | Pending Revision |
|
* | PendingRevision | 61 | Pending Revision |
|
||||||
* | PendingWon | 49 | 91. Pending Won |
|
* | PendingWon | 49 | 91. Pending Won |
|
||||||
* | Won | 29 | 95. Won |
|
* | Won | 29 | 95. Won |
|
||||||
@@ -65,6 +66,7 @@ export const OpportunityStatus = {
|
|||||||
QuoteSent: 43,
|
QuoteSent: 43,
|
||||||
ConfirmedQuote: 57,
|
ConfirmedQuote: 57,
|
||||||
Active: 58,
|
Active: 58,
|
||||||
|
ReadyToSend: 63,
|
||||||
PendingSent: 60,
|
PendingSent: 60,
|
||||||
PendingRevision: 61,
|
PendingRevision: 61,
|
||||||
PendingWon: 49,
|
PendingWon: 49,
|
||||||
@@ -180,19 +182,25 @@ const ALLOWED_TRANSITIONS: Record<number, Set<number>> = {
|
|||||||
[OpportunityStatus.PendingNew]: new Set([OpportunityStatus.New]),
|
[OpportunityStatus.PendingNew]: new Set([OpportunityStatus.New]),
|
||||||
|
|
||||||
[OpportunityStatus.New]: new Set([
|
[OpportunityStatus.New]: new Set([
|
||||||
|
OpportunityStatus.ReadyToSend,
|
||||||
OpportunityStatus.InternalReview,
|
OpportunityStatus.InternalReview,
|
||||||
OpportunityStatus.QuoteSent,
|
OpportunityStatus.QuoteSent,
|
||||||
OpportunityStatus.Canceled,
|
OpportunityStatus.Canceled,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
[OpportunityStatus.InternalReview]: new Set([
|
[OpportunityStatus.InternalReview]: new Set([
|
||||||
OpportunityStatus.PendingSent,
|
OpportunityStatus.ReadyToSend,
|
||||||
OpportunityStatus.PendingRevision,
|
OpportunityStatus.PendingRevision,
|
||||||
OpportunityStatus.QuoteSent, // reviewer manually sends
|
OpportunityStatus.QuoteSent, // reviewer manually sends
|
||||||
OpportunityStatus.Canceled,
|
OpportunityStatus.Canceled,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
[OpportunityStatus.PendingSent]: new Set([OpportunityStatus.QuoteSent]),
|
[OpportunityStatus.ReadyToSend]: new Set([OpportunityStatus.QuoteSent]),
|
||||||
|
|
||||||
|
[OpportunityStatus.PendingSent]: new Set([
|
||||||
|
OpportunityStatus.ReadyToSend,
|
||||||
|
OpportunityStatus.QuoteSent,
|
||||||
|
]),
|
||||||
|
|
||||||
[OpportunityStatus.PendingRevision]: new Set([OpportunityStatus.Active]),
|
[OpportunityStatus.PendingRevision]: new Set([OpportunityStatus.Active]),
|
||||||
|
|
||||||
@@ -214,6 +222,7 @@ const ALLOWED_TRANSITIONS: Record<number, Set<number>> = {
|
|||||||
]),
|
]),
|
||||||
|
|
||||||
[OpportunityStatus.Active]: new Set([
|
[OpportunityStatus.Active]: new Set([
|
||||||
|
OpportunityStatus.ReadyToSend,
|
||||||
OpportunityStatus.QuoteSent,
|
OpportunityStatus.QuoteSent,
|
||||||
OpportunityStatus.InternalReview,
|
OpportunityStatus.InternalReview,
|
||||||
OpportunityStatus.Canceled,
|
OpportunityStatus.Canceled,
|
||||||
@@ -275,7 +284,7 @@ export interface ReviewDecisionPayload extends BaseActionPayload {
|
|||||||
decision: "approve" | "reject" | "send" | "cancel";
|
decision: "approve" | "reject" | "send" | "cancel";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Transition from PendingSent → QuoteSent. */
|
/** Transition from ReadyToSend/PendingSent(deprecated) → QuoteSent. */
|
||||||
export interface SendQuotePayload extends BaseActionPayload {
|
export interface SendQuotePayload extends BaseActionPayload {
|
||||||
/**
|
/**
|
||||||
* If true, marks sent AND confirmed simultaneously.
|
* If true, marks sent AND confirmed simultaneously.
|
||||||
@@ -313,6 +322,9 @@ export interface SendQuotePayload extends BaseActionPayload {
|
|||||||
needsRevision?: boolean;
|
needsRevision?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mark opportunity as ready to send quote from send-capable statuses. */
|
||||||
|
export interface MarkReadyToSendPayload extends BaseActionPayload {}
|
||||||
|
|
||||||
/** Confirm receipt of a quote. */
|
/** Confirm receipt of a quote. */
|
||||||
export interface ConfirmQuotePayload extends BaseActionPayload {}
|
export interface ConfirmQuotePayload extends BaseActionPayload {}
|
||||||
|
|
||||||
@@ -351,6 +363,7 @@ export type WorkflowAction =
|
|||||||
| { action: "acceptNew"; payload: AcceptNewPayload }
|
| { action: "acceptNew"; payload: AcceptNewPayload }
|
||||||
| { action: "requestReview"; payload: RequestReviewPayload }
|
| { action: "requestReview"; payload: RequestReviewPayload }
|
||||||
| { action: "reviewDecision"; payload: ReviewDecisionPayload }
|
| { action: "reviewDecision"; payload: ReviewDecisionPayload }
|
||||||
|
| { action: "markReadyToSend"; payload: MarkReadyToSendPayload }
|
||||||
| { action: "sendQuote"; payload: SendQuotePayload }
|
| { action: "sendQuote"; payload: SendQuotePayload }
|
||||||
| { action: "confirmQuote"; payload: ConfirmQuotePayload }
|
| { action: "confirmQuote"; payload: ConfirmQuotePayload }
|
||||||
| { action: "finalize"; payload: FinalizePayload }
|
| { action: "finalize"; payload: FinalizePayload }
|
||||||
@@ -728,7 +741,7 @@ export async function transitionToInternalReview(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* InternalReview → PendingSent | PendingRevision | QuoteSent | Canceled
|
* InternalReview → ReadyToSend | PendingRevision | QuoteSent | Canceled
|
||||||
*
|
*
|
||||||
* Reviewer makes a decision on an opportunity in InternalReview.
|
* Reviewer makes a decision on an opportunity in InternalReview.
|
||||||
*/
|
*/
|
||||||
@@ -753,9 +766,9 @@ export async function handleReviewDecision(
|
|||||||
const activities: ActivityController[] = [];
|
const activities: ActivityController[] = [];
|
||||||
|
|
||||||
switch (payload.decision) {
|
switch (payload.decision) {
|
||||||
// ── Approve → PendingSent ──────────────────────────────────────────
|
// ── Approve → ReadyToSend ──────────────────────────────────────────
|
||||||
case "approve": {
|
case "approve": {
|
||||||
const targetStatus = OpportunityStatus.PendingSent;
|
const targetStatus = OpportunityStatus.ReadyToSend;
|
||||||
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
||||||
if (transErr) return fail(transErr, currentStatus);
|
if (transErr) return fail(transErr, currentStatus);
|
||||||
|
|
||||||
@@ -901,7 +914,7 @@ export async function handleReviewDecision(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PendingSent → QuoteSent (and its compound transitions)
|
* ReadyToSend/PendingSent(deprecated) → QuoteSent (and its compound transitions)
|
||||||
*
|
*
|
||||||
* Also handles New → QuoteSent (direct send, skipping review) and
|
* Also handles New → QuoteSent (direct send, skipping review) and
|
||||||
* Active → QuoteSent (re-send after revision).
|
* Active → QuoteSent (re-send after revision).
|
||||||
@@ -1172,6 +1185,54 @@ export async function transitionToQuoteSent(
|
|||||||
return ok(currentStatus, targetStatus, activities);
|
return ok(currentStatus, targetStatus, activities);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New/Active/PendingSent(deprecated) → ReadyToSend
|
||||||
|
*
|
||||||
|
* Allows staging an opportunity as ready without immediately sending.
|
||||||
|
*/
|
||||||
|
export async function transitionToReadyToSend(
|
||||||
|
opportunity: OpportunityController,
|
||||||
|
user: WorkflowUser,
|
||||||
|
payload: MarkReadyToSendPayload,
|
||||||
|
): Promise<WorkflowResult> {
|
||||||
|
const currentStatus = opportunity.statusCwId;
|
||||||
|
if (currentStatus == null) return fail("Opportunity has no current status.");
|
||||||
|
|
||||||
|
if (!hasPermission(user, WorkflowPermissions.SEND)) {
|
||||||
|
return fail(
|
||||||
|
`User lacks the "${WorkflowPermissions.SEND}" permission required to mark an opportunity ready to send.`,
|
||||||
|
currentStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStatus = OpportunityStatus.ReadyToSend;
|
||||||
|
const transErr = assertTransitionAllowed(currentStatus, targetStatus);
|
||||||
|
if (transErr) return fail(transErr, currentStatus);
|
||||||
|
|
||||||
|
const activity = await createWorkflowActivity({
|
||||||
|
name: `[Workflow] Marked ready to send — ${opportunity.name}`,
|
||||||
|
opportunityCwId: opportunity.cwOpportunityId,
|
||||||
|
companyCwId: opportunity.companyCwId,
|
||||||
|
assignToCwMemberId: user.cwMemberId,
|
||||||
|
notes: payload.note ?? "Marked ready to send.",
|
||||||
|
optimaType: OptimaType.OpportunityReview,
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncOpportunityStatus({
|
||||||
|
opportunityId: opportunity.cwOpportunityId,
|
||||||
|
statusCwId: targetStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleTimeEntry(
|
||||||
|
activity.cwActivityId,
|
||||||
|
user.cwMemberId,
|
||||||
|
payload,
|
||||||
|
payload.note ?? "Marked ready to send.",
|
||||||
|
);
|
||||||
|
|
||||||
|
return ok(currentStatus, targetStatus, [activity]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QuoteSent → ConfirmedQuote
|
* QuoteSent → ConfirmedQuote
|
||||||
*
|
*
|
||||||
@@ -1754,6 +1815,10 @@ export async function processOpportunityAction(
|
|||||||
result = await handleReviewDecision(opportunity, user, payload);
|
result = await handleReviewDecision(opportunity, user, payload);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "markReadyToSend":
|
||||||
|
result = await transitionToReadyToSend(opportunity, user, payload);
|
||||||
|
break;
|
||||||
|
|
||||||
case "sendQuote":
|
case "sendQuote":
|
||||||
result = await transitionToQuoteSent(opportunity, user, payload);
|
result = await transitionToQuoteSent(opportunity, user, payload);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ describe("transitionToInternalReview", () => {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
describe("handleReviewDecision", () => {
|
describe("handleReviewDecision", () => {
|
||||||
test("approve → PendingSent", async () => {
|
test("approve → ReadyToSend", async () => {
|
||||||
const opp = makeOpportunity({
|
const opp = makeOpportunity({
|
||||||
statusCwId: OpportunityStatus.InternalReview,
|
statusCwId: OpportunityStatus.InternalReview,
|
||||||
});
|
});
|
||||||
@@ -342,7 +342,7 @@ describe("handleReviewDecision", () => {
|
|||||||
note: "Looks good",
|
note: "Looks good",
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.newStatusId).toBe(OpportunityStatus.PendingSent);
|
expect(result.newStatusId).toBe(OpportunityStatus.ReadyToSend);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reject → PendingRevision", async () => {
|
test("reject → PendingRevision", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user