203 lines
8.9 KiB
Markdown
203 lines
8.9 KiB
Markdown
# Quote-to-RO Approved Services Sync
|
|
|
|
## Overview
|
|
|
|
When a quote is generated from a repair order (`?fromRO=RO_ID` URL parameter) and the user approves or declines services, the approved services should be written back to the originating RO's services array. This keeps the RO's service list up-to-date with the customer's decisions.
|
|
|
|
## Implementation
|
|
|
|
The sync happens in `src/components/quoteGenerator/QuoteSummary.tsx` at two points:
|
|
|
|
1. **`handleSave`** (Save Quote button) — after saving the quote record, if `repairOriginId` is set
|
|
2. **`ensureShareToken`** (Share with Customer / Send Email / Send Text) — after creating the quote record, if `repairOriginId` is set
|
|
|
|
### Sync Logic
|
|
|
|
```typescript
|
|
if (repairOriginId) {
|
|
const approvedQuoteSvcs = services.filter((s) => s.approved);
|
|
if (approvedQuoteSvcs.length > 0) {
|
|
// 1. Load current RO
|
|
const ro = await pb.collection('repairOrders').getOne(repairOriginId);
|
|
let roServices: any[] = [];
|
|
const raw = (ro as any).services;
|
|
if (typeof raw === 'string' && raw.trim()) {
|
|
try { roServices = JSON.parse(raw); } catch { roServices = []; }
|
|
} else if (Array.isArray(raw)) {
|
|
roServices = raw;
|
|
}
|
|
|
|
// 2. Merge approved quote services into RO services
|
|
const rate = settings.defaultLaborRate || 95;
|
|
for (const qs of approvedQuoteSvcs) {
|
|
const price = parseFloat(String(qs.price ?? 0)) || 0;
|
|
const roSvc = {
|
|
id: `qs-${qs.id}`,
|
|
name: qs.name || '',
|
|
description: qs.explanation || '',
|
|
laborHours: Math.round((price / rate) * 100) / 100,
|
|
laborRate: rate,
|
|
partsCost: 0,
|
|
total: price,
|
|
status: 'pending' as const,
|
|
technician: '',
|
|
};
|
|
const existingIdx = roServices.findIndex((s) => s.name === qs.name);
|
|
if (existingIdx >= 0) {
|
|
// Update existing — preserve status and technician
|
|
roServices[existingIdx] = { ...roServices[existingIdx], ...roSvc,
|
|
status: roServices[existingIdx].status || 'pending' };
|
|
} else {
|
|
roServices.push(roSvc);
|
|
}
|
|
}
|
|
|
|
// 3. Save updated RO
|
|
await pb.collection('repairOrders').update(repairOriginId, {
|
|
services: JSON.stringify(roServices),
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### Merge Rules
|
|
|
|
- **Same name = same service**: matched by `s.name === qs.name`
|
|
- **Existing service updated**: price/total/hours/rate overwritten, but `status` and `technician` are preserved
|
|
- **New service appended**: added as a new entry with status `'pending'`
|
|
- **Declined/pending services ignored**: only `s.approved === true` services get written back
|
|
|
|
### `repairOriginId` Flow
|
|
|
|
1. User opens RO → clicks "Generate Quote" → URL gets `?fromRO=RO_ID`
|
|
2. `QuoteGenerator.tsx` reads the param, sets `repairOriginId` state (line 74)
|
|
3. Passes `repairOriginId` to `QuoteSummary` component (line 601)
|
|
4. `QuoteSummary` includes `repairOrderId: repairOriginId` in the saved quote record (line 123/244)
|
|
5. After save, the sync block writes approved services back to that RO
|
|
|
|
### EnsureShareToken Sync Gap
|
|
|
|
The `ensureShareToken` function has a logic gap critical for debugging:
|
|
|
|
```typescript
|
|
let quoteId = editId;
|
|
if (!quoteId) {
|
|
// ... create new quote record ...
|
|
// ... SYNC approved services to RO ...
|
|
}
|
|
// ... generate share token ...
|
|
```
|
|
|
|
When `editId` is set (editing an existing quote), the `if (!quoteId)` block is **entirely skipped** — including the sync code. This means:
|
|
|
|
- **Save path**: `handleSave` sync runs for BOTH new and edit saves. Save-then-Share works.
|
|
- **Share-only path**: If the user Shares an existing quote WITHOUT first clicking Save, the sync is skipped. Approved services won't transfer.
|
|
- **Mitigation**: Extract the copy-pasted sync logic into a shared function called from both paths.
|
|
|
|
### Root Cause Patterns
|
|
|
|
Three root cause categories emerged from investigation:
|
|
|
|
### 1. Auth Ownership Mismatch (404, not 403)
|
|
|
|
PocketBase's `userId = @request.auth.id` rule returns **404** (not 403) when the authenticated user doesn't own the record. If `demo@shop.com` logs in but the RO belongs to `mani8994@gmail.com`, the sync silently fails — the inner catch block shows a toast that may go unnoticed.
|
|
|
|
**Diagnosis:** Check userId on both quote and RO:
|
|
```bash
|
|
curl -s -H "Authorization: $APP_TOKEN" \
|
|
"http://host:8091/api/collections/quotes/records/QUOTE_ID?fields=id,userId,repairOrderId"
|
|
curl -s -H "Authorization: $APP_TOKEN" \
|
|
"http://host:8091/api/collections/repairOrders/records/RO_ID?fields=id,userId"
|
|
```
|
|
Both must match the authenticated user's ID.
|
|
|
|
### 2. Missing `repairOrderId` on Legacy Quotes
|
|
|
|
Quotes created before the `repairOrderId` field was added (migration `1739999000006`) have an empty field. The edit-load effect won't set `repairOriginId` for these quotes, so the sync code exits early.
|
|
|
|
**Diagnosis:** Count quotes with non-empty repairOrderId:
|
|
```bash
|
|
curl -s -H "Authorization: $SUPERTOKEN" \
|
|
"http://host:8091/api/collections/quotes/records?filter=repairOrderId!=%27%27&perPage=1"
|
|
# %27 is URL-encoded single quote. In PocketBase filter syntax: repairOrderId!='' means "not empty"
|
|
```
|
|
|
|
### 3. Zustand Persist vs Edit-Load (Not a Real Race)
|
|
|
|
The Zustand `persist` middleware rehydrates synchronously from localStorage on first render, BEFORE the edit-load `useEffect` fires. The effect's `useQuoteStore.setState()` correctly overwrites with PocketBase data. No race condition exists in practice.
|
|
|
|
## Debugging: Direct API Verification
|
|
|
|
When the sync looks correct in code but the RO doesn't update, verify at the API level:
|
|
|
|
**1. Check repairOrderId on quotes:**
|
|
```bash
|
|
APP_TOKEN=$(curl -s -X POST http://host:8091/api/collections/users/auth-with-password \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"identity":"user@example.com","password":"pwd"}' | python3 -c "import json,sys;print(json.load(sys.stdin).get('token',''))")
|
|
|
|
curl -s -H "Authorization: $APP_TOKEN" \
|
|
"http://host:8091/api/collections/quotes/records?filter=repairOrderId!=%27%27&perPage=10&fields=id,customerName,repairOrderId,services" | python3 -c "
|
|
import json,sys; d=json.load(sys.stdin)
|
|
for q in d.get('items',[]):
|
|
svcs = json.loads(q.get('services','[]')) if isinstance(q.get('services'),str) else (q.get('services') or [])
|
|
approved = len([s for s in svcs if s.get('approved')])
|
|
print(f'{q[\"id\"][:12]} {q.get(\"customerName\",\"?\")[:20]} roId={q.get(\"repairOrderId\",\"\")[:12]} svcs={len(svcs)} approved={approved}')
|
|
"
|
|
```
|
|
|
|
**2. Check services on the linked RO:**
|
|
```bash
|
|
curl -s -H "Authorization: $APP_TOKEN" \
|
|
"http://host:8091/api/collections/repairOrders/records/RO_ID?skipTotal=1" | python3 -c "
|
|
import json,sys; d=json.load(sys.stdin)
|
|
svcs = json.loads(d.get('services','[]')) if isinstance(d.get('services'),str) else (d.get('services') or [])
|
|
print(f'Services in RO: {len(svcs)}')
|
|
"
|
|
```
|
|
|
|
**3. Manually test the update the sync code performs:**
|
|
```bash
|
|
curl -s -X PATCH "http://host:8091/api/collections/repairOrders/records/RO_ID" \
|
|
-H "Authorization: Bearer $APP_TOKEN" \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"services": "[{\"name\":\"Test\",\"total\":100,\"status\":\"pending\"}]"}'
|
|
```
|
|
If this succeeds, the code logic is correct — the issue is in the UI execution path.
|
|
|
|
**4. Auth ownership check:**
|
|
```bash
|
|
curl -s -H "Authorization: $APP_TOKEN" \
|
|
"http://host:8091/api/collections/quotes/records/QUOTE_ID?skipTotal=1&fields=id,userId,repairOrderId"
|
|
curl -s -H "Authorization: $APP_TOKEN" \
|
|
"http://host:8091/api/collections/repairOrders/records/RO_ID?skipTotal=1&fields=id,userId"
|
|
```
|
|
The `userId` fields must match. If they differ, the `userId = @request.auth.id` rule blocks the update with 404 (not 403).
|
|
|
|
### Debugging: Creating Test Data with Known Ownership
|
|
|
|
To isolate ownership variables, create test data owned by the same user:
|
|
|
|
```bash
|
|
# Authenticate and get user ID
|
|
RESP=$(curl -s -X POST http://host:8091/api/collections/users/auth-with-password \
|
|
-H 'Content-Type: application/json' \
|
|
-d '{"identity":"user@example.com","password":"pwd"}')
|
|
TOKEN=$(echo "$RESP" | python3 -c "import json,sys;print(json.load(sys.stdin).get('token',''))")
|
|
AUTH_ID=$(echo "$RESP" | python3 -c "import json,sys;print(json.load(sys.stdin).get('record',{}).get('id',''))")
|
|
|
|
# Create a test RO
|
|
curl -s -X POST "http://host:8091/api/collections/repairOrders/records" \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "{\"userId\":\"$AUTH_ID\",\"customerName\":\"Test\",\"roNumber\":\"TEST-001\",\"status\":\"active\",\"services\":\"[]\"}"
|
|
|
|
# Create a linked quote with approved services
|
|
curl -s -X POST "http://host:8091/api/collections/quotes/records" \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "{\"userId\":\"$AUTH_ID\",\"customerName\":\"Test\",\"repairOrderId\":\"RO_ID\",\"services\":\"[{\\\"id\\\":\\\"qs-1\\\",\\\"name\\\":\\\"Service\\\",\\\"price\\\":100,\\\"approved\\\":true}]\"}"
|
|
```
|
|
|
|
This ensures the test reproduces the same-owner scenario. If the sync still fails with same-owner data, the bug is in the code logic, not the auth layer.
|