End of day updates, almost at completion of live cron setup with bash script wrapper now as ell

This commit is contained in:
2026-05-04 18:22:39 +10:00
parent 2739606d6a
commit 868632bd21
3 changed files with 295 additions and 61 deletions
+145 -60
View File
@@ -1,73 +1,158 @@
# ServiceM8 Project Progress # ServiceM8 Project Progress
## Current Quote Template → JobMaterials Pipeline ## Current Direction — Soft Release Poller + Apply Pipeline
### Live receiver We changed tune from relying mainly on ServiceM8 `form.response_created` webhooks to using a scheduled API poller as the operational safety-net/primary soft-release path.
- `servicem8_webhook_receiver.py`
- receives `form.response_created`
- stores the raw webhook payload in `servicem8_webhooks.db`
- checks incoming form responses for the Quote Template `form_uuid`
- if matched, parses and queues the derived jobMaterials payload to:
- `quote-template-jobmaterials-queue.jsonl`
- does **not** perform live ServiceM8 writes at this stage
### Parser Reason: ServiceM8 form webhooks are useful, but during testing some expected form completions did not reliably appear at the FastAPI listener. The ServiceM8 API endpoint `/api_1.0/formresponse.json` can be queried with a timestamp filter, so the safer operational pattern is now:
- `servicem8-quote-template-parser.py`
- parses Quote Template `field_data`
- extracts:
- description of works
- `Item 1..12` include lines
- `Works excluded 1..4` exclude lines
- extra descriptive include rows such as labour/scaffolding/equipment fields
- builds normalized `desired_job_materials`
### Create/apply script 1. poll recent form responses with `timestamp gt 'YYYY-MM-DD HH:MM:SS'`
- `servicem8-create-jobmaterials-from-form-response.py` 2. store every returned API row locally
- standalone script 3. detect Quote Template form responses
- consumes a Quote Template form response JSON payload 4. parse them into desired jobMaterial rows
- builds ServiceM8 Job Material API payloads 5. apply only previously unapplied Quote Template responses to ServiceM8
- runs in **dry-run by default** 6. track created jobMaterial UUIDs locally to avoid duplicate applies
- supports live create later with `--apply`
- records created/generated mappings into local state DB
### Local state tracking This keeps the live webhook receiver useful as capture/diagnostics, but no longer depends on webhook delivery for completeness.
- `servicem8_quote_materials_state.db`
- local SQLite DB for tracking generated jobMaterials
- intended fields include:
- job UUID
- form response UUID
- created job material UUID
- kind/source metadata
### Queue/prepared output ## Active Soft-Release Flow
- `quote-template-jobmaterials-queue.jsonl`
- lightweight queue/output file written by webhook stage
- contains parsed/prepared `desired_job_materials` objects
- no live update performed yet
### Inspector ### 1. Poll ServiceM8 form responses
- `servicem8_inspector.py` - Script: `poll_form_responses_since.py`
- read-only browser for webhook DB - API endpoint: `/api_1.0/formresponse.json`
- now also includes visibility of generated-materials state DB - Confirmed filter syntax:
- `?$filter=timestamp gt '2026-05-04 10:00:00'`
- Default operational DB:
- `servicem8_formresponse_poll.db`
- Poll queue/output:
- `quote-template-jobmaterials-poll-queue.jsonl`
- Quote Template form UUID:
- `3621b6be-1d19-4756-9ab4-9d5e4120f6d9`
The poller stores all fetched form responses in `form_responses_raw`, then parses Quote Template matches into `quote_template_form_responses`.
### 2. Parse Quote Template responses
- Parser: `servicem8_quote_template_parser.py`
- Extracts:
- description of works
- `Item 1..12` include lines
- `Works excluded 1..4` exclude lines
- extra descriptive include rows such as labour/materials/scaffolding/equipment fields
- Builds normalized `desired_job_materials` rows.
### 3. Select/apply parsed Quote Template responses
- Script: `apply_polled_quote_template_jobmaterials.py`
- Safe behaviour built in:
- processes one `form_response_uuid` at a time when called directly
- dry-run by default
- `--apply` performs live ServiceM8 `jobmaterial.json` creates
- refuses duplicate apply when generated material rows already exist for that form response unless `--force` is used
- Apply tracking tables in `servicem8_formresponse_poll.db`:
- `quote_template_apply_runs`
- `quote_template_apply_run_rows`
- Created ServiceM8 job material mappings are recorded in:
- `servicem8_quote_materials_state.db`
Current apply payload rules:
- All rows get:
- `tax_rate_uuid = 84e4dd28-06b3-452b-a796-1f58a20ac49b`
- `quantity = "0"`
- `price = ""`
- `displayed_amount = ""`
- `displayed_amount_is_tax_inclusive = ""`
- Header material UUIDs:
- `include_header``1924893b-917f-474a-adaa-2093bd622d4b`
- `exclude_header``4947bfd7-4875-48f7-9caf-2093b9751b9b`
- Non-header quote rows currently use:
- `f78b1d23-b9fa-40fe-a806-2425fe09cc0b`
### 4. Wrapper for scheduled/operational use
- Script: `poll_and_apply_quote_templates.sh`
- Default behaviour:
- polls the last 24 hours from script start
- stores/parses results
- applies any parsed Quote Template responses that are not already marked/applied
- logs each run under `logs/poll-and-apply-YYYYMMDD-HHMMSS.log`
- Examples:
- `./poll_and_apply_quote_templates.sh`
- `./poll_and_apply_quote_templates.sh --hours 48`
- `./poll_and_apply_quote_templates.sh --since '2026-05-04 08:00:00'`
This is the proposed scheduled entry point for soft release, e.g. every 1030 minutes.
## Live Webhook Receiver Status
### Receiver
- Script: `servicem8_webhook_receiver.py`
- Still receives/stores:
- event webhooks
- object webhooks
- form-response webhooks
- DB:
- `servicem8_webhooks.db`
- Quote Template webhook queue:
- `quote-template-jobmaterials-queue.jsonl`
Important: webhook handling still does **not** perform live ServiceM8 writes. Heavy work stays outside the FastAPI webhook request path.
## Inspector / Web Viewer
- Script: `servicem8_inspector.py`
- Current intended views include:
- webhook events
- webhook objects
- webhook form responses
- polled form responses
- parsed polled Quote Template responses
- poll runs
- dry-run/apply runs
- generated material state
Note: the code was updated and tested on a temporary localhost port, but the existing live inspector process may need a manual/service restart before all new pages appear in the running viewer.
## Required Files for Current Soft Release
### Scripts
- `poll_and_apply_quote_templates.sh` — scheduled wrapper / main operational entry point
- `poll_form_responses_since.py` — polls ServiceM8 and populates poll DB
- `apply_polled_quote_template_jobmaterials.py` — applies parsed responses to ServiceM8
- `servicem8_quote_template_parser.py` — parsing logic
- `servicem8_inspector.py` — web inspection/progress viewer
- `servicem8_webhook_receiver.py` — still useful for webhook capture/diagnostics
### Databases / state
- `servicem8_formresponse_poll.db` — poll results, parsed quote responses, apply run status
- `servicem8_quote_materials_state.db` — created jobMaterial mapping/state to avoid duplicates
- `servicem8_webhooks.db` — webhook capture/archive/diagnostics
### Queue/log files
- `quote-template-jobmaterials-poll-queue.jsonl` — poll-derived parsed queue/output
- `logs/poll-and-apply-*.log` — wrapper run logs
- `quote-template-jobmaterials-queue.jsonl` — older webhook-derived queue; still useful but not the primary soft-release path
## Current Status ## Current Status
Everything is staged and connected up to the point of:
- webhook receive
- UUID trigger check
- form parsing
- local queueing
- dry-run jobMaterial payload generation
- local state DB support
- inspector visibility
## Not Yet Enabled Operational soft-release pieces are now in place:
- actual live POST creation of Job Materials into ServiceM8 during webhook processing - API polling confirmed
- any automatic update/delete reconciliation against live ServiceM8 records - Quote Template detection confirmed
- parsing confirmed
- dry-run/apply command path tested
- payload adjusted for current ServiceM8 requirements
- duplicate-apply guard in place
- wrapper script created for scheduled operation
- inspector updated for progress visibility
## Not Yet Done / Next Steps
- Restart/reload the live inspector process so the new poll/apply pages are available in the active web viewer.
- Decide schedule interval for `poll_and_apply_quote_templates.sh` — likely every 10 or 30 minutes.
- Run the wrapper manually for a soft-release smoke test with a controlled recent form response.
- After confidence builds, wire the wrapper into cron/system scheduling.
- Future hardening: add reconciliation/update/delete behaviour if ServiceM8 quote form responses are edited after initial apply.
## Design Notes ## Design Notes
- Heavy lifting is intentionally kept **out of the live webhook handler**.
- The webhook handler is used only for: - Webhooks remain lightweight and non-mutating.
- capture - Polling is now the reliable source of completeness.
- UUID gate - Applying to ServiceM8 is tracked locally and guarded against duplicates.
- parse/prepare/queue - The wrapper intentionally skips dry-run for soft release, but the underlying apply script still supports dry-run and duplicate protection.
- Live ServiceM8 mutation remains a separate step/script for safety.
+12 -1
View File
@@ -30,6 +30,8 @@ POLL_DB_PATH = Path(os.getenv("WEBHOOK_POLL_DB_PATH", SCRIPT_DIR / "servicem8_fo
BASE_URL = os.getenv("SERVICEM8_BASE_URL", "https://api.servicem8.com/api_1.0") BASE_URL = os.getenv("SERVICEM8_BASE_URL", "https://api.servicem8.com/api_1.0")
REQUEST_TIMEOUT = int(os.getenv("SERVICEM8_TIMEOUT", "30")) REQUEST_TIMEOUT = int(os.getenv("SERVICEM8_TIMEOUT", "30"))
DEV_QUOTE_MATERIAL_UUID = "f78b1d23-b9fa-40fe-a806-2425fe09cc0b" DEV_QUOTE_MATERIAL_UUID = "f78b1d23-b9fa-40fe-a806-2425fe09cc0b"
QUOTE_INCLUDE_HEADER_MATERIAL_UUID = "1924893b-917f-474a-adaa-2093bd622d4b"
QUOTE_EXCLUDE_HEADER_MATERIAL_UUID = "4947bfd7-4875-48f7-9caf-2093b9751b9b"
DEV_QUOTE_TAX_RATE_UUID = "84e4dd28-06b3-452b-a796-1f58a20ac49b" DEV_QUOTE_TAX_RATE_UUID = "84e4dd28-06b3-452b-a796-1f58a20ac49b"
@@ -37,10 +39,19 @@ def utc_now() -> str:
return datetime.now(timezone.utc).isoformat() return datetime.now(timezone.utc).isoformat()
def material_uuid_for_row(row: dict) -> str:
kind = row.get("kind", "")
if kind == "include_header":
return QUOTE_INCLUDE_HEADER_MATERIAL_UUID
if kind == "exclude_header":
return QUOTE_EXCLUDE_HEADER_MATERIAL_UUID
return DEV_QUOTE_MATERIAL_UUID
def build_payload(job_uuid: str, row: dict) -> dict: def build_payload(job_uuid: str, row: dict) -> dict:
return { return {
"job_uuid": job_uuid, "job_uuid": job_uuid,
"material_uuid": DEV_QUOTE_MATERIAL_UUID, "material_uuid": material_uuid_for_row(row),
"tax_rate_uuid": DEV_QUOTE_TAX_RATE_UUID, "tax_rate_uuid": DEV_QUOTE_TAX_RATE_UUID,
"name": row["name"], "name": row["name"],
"quantity": "0", "quantity": "0",
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
set -euo pipefail
# Poll ServiceM8 form responses, parse Quote Template responses, then apply any
# newly/unapplied parsed quote responses to ServiceM8 jobMaterials.
#
# Default window: last 24 hours from script start, in local system time.
# Override with:
# --since 'YYYY-MM-DD HH:MM:SS'
# --hours 48
#
# This wrapper intentionally skips dry-run and calls --apply. The apply script
# still refuses duplicate applies unless --force is explicitly passed through.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
POLL_SCRIPT="$SCRIPT_DIR/poll_form_responses_since.py"
APPLY_SCRIPT="$SCRIPT_DIR/apply_polled_quote_template_jobmaterials.py"
DB_PATH="${WEBHOOK_POLL_DB_PATH:-$SCRIPT_DIR/servicem8_formresponse_poll.db}"
LOG_DIR="${WEBHOOK_RUN_LOG_DIR:-$SCRIPT_DIR/logs}"
QUOTE_TEMPLATE_FORM_UUID="${SERVICEM8_QUOTE_TEMPLATE_FORM_UUID:-3621b6be-1d19-4756-9ab4-9d5e4120f6d9}"
SINCE=""
HOURS="24"
FORCE="0"
usage() {
cat <<EOF
Usage: $0 [--since 'YYYY-MM-DD HH:MM:SS'] [--hours N] [--force]
Examples:
$0
$0 --hours 48
$0 --since '2026-05-04 08:00:00'
This will:
1. Poll /formresponse.json using timestamp gt SINCE
2. Store/parse Quote Template responses into $DB_PATH
3. Apply parsed responses that do not already have generated materials recorded
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--since)
SINCE="${2:-}"
shift 2
;;
--hours)
HOURS="${2:-}"
shift 2
;;
--force)
FORCE="1"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "$SINCE" ]]; then
SINCE="$(date -d "-${HOURS} hours" '+%Y-%m-%d %H:%M:%S')"
fi
mkdir -p "$LOG_DIR"
RUN_ID="$(date '+%Y%m%d-%H%M%S')"
LOG_FILE="$LOG_DIR/poll-and-apply-$RUN_ID.log"
exec > >(tee -a "$LOG_FILE") 2>&1
echo "== ServiceM8 Quote Template poll/apply run =="
echo "Started: $(date --iso-8601=seconds)"
echo "Since: $SINCE"
echo "DB: $DB_PATH"
echo "Log: $LOG_FILE"
echo
echo "== Polling form responses =="
"$POLL_SCRIPT" --since "$SINCE" --summary-limit 20
echo
echo "== Finding unapplied parsed Quote Template responses =="
mapfile -t FORM_RESPONSE_UUIDS < <(
python3 - "$DB_PATH" "$QUOTE_TEMPLATE_FORM_UUID" <<'PY'
import sqlite3
import sys
poll_db, quote_form_uuid = sys.argv[1], sys.argv[2]
conn = sqlite3.connect(poll_db)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
select q.form_response_uuid
from quote_template_form_responses q
left join form_responses_raw r on r.uuid = q.form_response_uuid
where q.form_uuid = ?
and coalesce(q.process_status, '') != 'applied'
order by coalesce(r.timestamp, q.discovered_at) asc, q.form_response_uuid asc
""",
(quote_form_uuid,),
).fetchall()
for row in rows:
print(row["form_response_uuid"])
PY
)
if [[ ${#FORM_RESPONSE_UUIDS[@]} -eq 0 ]]; then
echo "No unapplied Quote Template responses found."
echo "Finished: $(date --iso-8601=seconds)"
exit 0
fi
printf 'Found %d unapplied Quote Template response(s):\n' "${#FORM_RESPONSE_UUIDS[@]}"
printf ' - %s\n' "${FORM_RESPONSE_UUIDS[@]}"
echo
echo "== Applying to ServiceM8 =="
APPLY_ARGS=(--apply --pretty)
if [[ "$FORCE" == "1" ]]; then
APPLY_ARGS+=(--force)
fi
for uuid in "${FORM_RESPONSE_UUIDS[@]}"; do
echo
echo "-- Applying form_response_uuid=$uuid --"
"$APPLY_SCRIPT" --uuid "$uuid" "${APPLY_ARGS[@]}"
done
echo
echo "Finished: $(date --iso-8601=seconds)"
echo "Log: $LOG_FILE"