+ *
+ * It closes on a press outside itself and outside its own field, and on
+ * Escape. It deliberately does not close on blur or on scroll: focus leaves
+ * the page for the native photo picker, and scrolling moves the flyout with
+ * its field anyway. Nothing is lost by closing, because every answer is saved
+ * as it is typed.
+ *
+ * A bottom sheet is placed by its stylesheet, so it only takes the
+ * dismissal half of this.
+ */
+export default modifier(function inspectionFlyout(element, [anchor], { presentation = 'floating', onDismiss, focus } = {}) {
+ if (!(anchor instanceof Element)) {
+ return;
+ }
+
+ const container = anchor.closest('.inspection-sheet') ?? document.body;
+ const floating = presentation === 'floating';
+ let frame = null;
+ let revealed = false;
+ let revealTimer = null;
+
+ const place = () => {
+ if (!floating || !element.isConnected || !anchor.isConnected) {
+ return;
+ }
+
+ cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(() => {
+ placeFlyout(element, anchor, container);
+
+ // A panel that opens half off the page is not natural. Bring it
+ // into view once, by the least scroll that will do it, as soon as
+ // it has finished opening; after that the inspector is in charge
+ // of the scrolling.
+ if (!revealed) {
+ revealed = true;
+ revealTimer = afterOpening(element, () => {
+ if (element.isConnected && anchor.isConnected) {
+ revealFlyout(placeFlyout(element, anchor, container));
+ }
+ });
+ }
+ });
+ };
+
+ const dismiss = (reason) => {
+ if (typeof onDismiss === 'function') {
+ onDismiss(reason);
+ }
+ };
+
+ // A press anywhere but the flyout or its own field closes it. Pointerdown,
+ // not click, so pressing another field's Fail closes this one first.
+ const onPointerDown = (event) => {
+ const target = event.target;
+
+ if (element.contains(target) || anchor.contains(target)) {
+ return;
+ }
+
+ dismiss('outside');
+ };
+
+ const onKeyDown = (event) => {
+ if (event.key === 'Escape') {
+ event.stopPropagation();
+ dismiss('escape');
+ }
+ };
+
+ document.addEventListener('pointerdown', onPointerDown, true);
+ document.addEventListener('keydown', onKeyDown, true);
+ window.addEventListener('resize', place);
+
+ // Re-place when anything around it changes height: a field above it
+ // gaining a line, or the flyout itself growing as a photo is added.
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(place) : null;
+ observer?.observe(container);
+ observer?.observe(element);
+
+ place();
+
+ if (focus) {
+ requestAnimationFrame(() => {
+ const target = focus === true ? element : element.querySelector(focus);
+ target?.focus({ preventScroll: true });
+ });
+ }
+
+ return () => {
+ cancelAnimationFrame(frame);
+ clearTimeout(revealTimer);
+ document.removeEventListener('pointerdown', onPointerDown, true);
+ document.removeEventListener('keydown', onKeyDown, true);
+ window.removeEventListener('resize', place);
+ observer?.disconnect();
+ };
+});
diff --git a/addon/modifiers/sync-value.js b/addon/modifiers/sync-value.js
new file mode 100644
index 000000000..0618c0cf5
--- /dev/null
+++ b/addon/modifiers/sync-value.js
@@ -0,0 +1,26 @@
+import { modifier } from 'ember-modifier';
+
+/**
+ * Keep an input's value in step with a bound one, without taking the caret.
+ *
+ * Binding `value={{@value}}` on an input whose every keystroke re-renders the
+ * component sends the caret to the end mid-word, which is what made the form
+ * builder's group inputs unusable. Leaving the value unbound instead means an
+ * input never shows a value that arrives after it was rendered — the stored
+ * answers an inspection loads a moment after the sheet appears.
+ *
+ * So write the value in, and only while the field is not being typed in.
+ *
+ *
+ */
+export default modifier(function syncValue(element, [value]) {
+ if (element.ownerDocument?.activeElement === element) {
+ return;
+ }
+
+ const next = value === null || value === undefined ? '' : String(value);
+
+ if (element.value !== next) {
+ element.value = next;
+ }
+});
diff --git a/addon/modifiers/when-changed.js b/addon/modifiers/when-changed.js
new file mode 100644
index 000000000..a3b50458b
--- /dev/null
+++ b/addon/modifiers/when-changed.js
@@ -0,0 +1,25 @@
+import { modifier } from 'ember-modifier';
+
+/** No value has been seen for this element yet — distinct from `undefined`. */
+const NEVER = Symbol('never');
+const seen = new WeakMap();
+
+/**
+ * Run something when a value changes, but not when it first appears.
+ *
+ * `{{did-update}}` does this and is deprecated for it. The distinction it does
+ * not make, and this does, is between the first render and a later change: a
+ * component that already loads its own data on construction must not load it
+ * again the moment it is inserted.
+ *
+ *
+ */
+export default modifier(function whenChanged(element, [value, callback]) {
+ const previous = seen.has(element) ? seen.get(element) : NEVER;
+
+ seen.set(element, value);
+
+ if (previous !== NEVER && previous !== value && typeof callback === 'function') {
+ callback(value);
+ }
+});
diff --git a/addon/routes.js b/addon/routes.js
index a60c70b1a..6a03c807c 100644
--- a/addon/routes.js
+++ b/addon/routes.js
@@ -227,6 +227,28 @@ export default buildRoutes(function () {
this.route('tracking');
});
this.route('maintenance', function () {
+ this.route('inspection-forms', function () {
+ this.route('index', { path: '/' }, function () {
+ this.route('new');
+ this.route('edit', { path: '/edit/:public_id' });
+ this.route('details', { path: '/:public_id' }, function () {
+ this.route('index', { path: '/' });
+ });
+ });
+ });
+
+ this.route('inspection-submissions', function () {
+ this.route('index', { path: '/' }, function () {
+ this.route('new');
+ this.route('edit', { path: '/edit/:public_id' });
+ this.route('details', { path: '/:public_id' }, function () {
+ this.route('index', { path: '/' });
+ this.route('photos');
+ this.route('audit');
+ });
+ });
+ });
+
this.route('schedules', function () {
this.route('index', { path: '/' }, function () {
this.route('new');
diff --git a/addon/routes/maintenance/inspection-forms.js b/addon/routes/maintenance/inspection-forms.js
new file mode 100644
index 000000000..97a8c467a
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionFormsRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-forms/index.js b/addon/routes/maintenance/inspection-forms/index.js
new file mode 100644
index 000000000..3ddda8c95
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index.js
@@ -0,0 +1,22 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class MaintenanceInspectionFormsIndexRoute extends Route {
+ @service store;
+
+ queryParams = {
+ page: { refreshModel: true },
+ limit: { refreshModel: true },
+ sort: { refreshModel: true },
+ query: { refreshModel: true },
+ public_id: { refreshModel: true },
+ status: { refreshModel: true },
+ type: { refreshModel: true },
+ created_at: { refreshModel: true },
+ updated_at: { refreshModel: true },
+ };
+
+ model(params) {
+ return this.store.query('inspection-form', { ...params });
+ }
+}
diff --git a/addon/routes/maintenance/inspection-forms/index/details.js b/addon/routes/maintenance/inspection-forms/index/details.js
new file mode 100644
index 000000000..202e83766
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/details.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionFormsIndexDetailsRoute extends Route {
+ @service store;
+ @service hostRouter;
+ @service notifications;
+
+ model({ public_id }) {
+ return this.store.findRecord('inspection-form', public_id);
+ }
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('maintenance.inspection-forms.index');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-forms/index/details/index.js b/addon/routes/maintenance/inspection-forms/index/details/index.js
new file mode 100644
index 000000000..b8b2de553
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/details/index.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionFormsIndexDetailsIndexRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-forms/index/edit.js b/addon/routes/maintenance/inspection-forms/index/edit.js
new file mode 100644
index 000000000..f8b15d6dd
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/edit.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionFormsIndexEditRoute extends Route {
+ @service store;
+ @service hostRouter;
+ @service notifications;
+
+ model({ public_id }) {
+ return this.store.findRecord('inspection-form', public_id);
+ }
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('maintenance.inspection-forms.index');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-forms/index/new.js b/addon/routes/maintenance/inspection-forms/index/new.js
new file mode 100644
index 000000000..96a813892
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/new.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionFormsIndexNewRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-submissions.js b/addon/routes/maintenance/inspection-submissions.js
new file mode 100644
index 000000000..73b235dce
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-submissions/index.js b/addon/routes/maintenance/inspection-submissions/index.js
new file mode 100644
index 000000000..48c87642b
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index.js
@@ -0,0 +1,25 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class MaintenanceInspectionSubmissionsIndexRoute extends Route {
+ @service store;
+
+ queryParams = {
+ page: { refreshModel: true },
+ limit: { refreshModel: true },
+ sort: { refreshModel: true },
+ query: { refreshModel: true },
+ public_id: { refreshModel: true },
+ status: { refreshModel: true },
+ result: { refreshModel: true },
+ type: { refreshModel: true },
+ vehicle: { refreshModel: true },
+ driver: { refreshModel: true },
+ created_at: { refreshModel: true },
+ updated_at: { refreshModel: true },
+ };
+
+ model(params) {
+ return this.store.query('inspection-submission', { ...params });
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details.js b/addon/routes/maintenance/inspection-submissions/index/details.js
new file mode 100644
index 000000000..ebb148013
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/details.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsRoute extends Route {
+ @service store;
+ @service hostRouter;
+ @service notifications;
+
+ model({ public_id }) {
+ return this.store.findRecord('inspection-submission', public_id);
+ }
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('maintenance.inspection-submissions.index');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details/audit.js b/addon/routes/maintenance/inspection-submissions/index/details/audit.js
new file mode 100644
index 000000000..63707bc25
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/details/audit.js
@@ -0,0 +1,8 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsAuditRoute extends Route {
+ /** The tab renders the submission the record panel is showing. */
+ model() {
+ return this.modelFor('maintenance.inspection-submissions.index.details');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details/index.js b/addon/routes/maintenance/inspection-submissions/index/details/index.js
new file mode 100644
index 000000000..5ade01403
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/details/index.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsIndexRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details/photos.js b/addon/routes/maintenance/inspection-submissions/index/details/photos.js
new file mode 100644
index 000000000..a493628b0
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/details/photos.js
@@ -0,0 +1,8 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsPhotosRoute extends Route {
+ /** The tab renders the submission the record panel is showing. */
+ model() {
+ return this.modelFor('maintenance.inspection-submissions.index.details');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/edit.js b/addon/routes/maintenance/inspection-submissions/index/edit.js
new file mode 100644
index 000000000..9e6f0bb2f
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/edit.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionSubmissionsIndexEditRoute extends Route {
+ @service store;
+ @service hostRouter;
+ @service notifications;
+
+ model({ public_id }) {
+ return this.store.findRecord('inspection-submission', public_id);
+ }
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('maintenance.inspection-submissions.index');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/new.js b/addon/routes/maintenance/inspection-submissions/index/new.js
new file mode 100644
index 000000000..f5c57fe09
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/new.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsIndexNewRoute extends Route {}
diff --git a/addon/services/inspection-form-actions.js b/addon/services/inspection-form-actions.js
new file mode 100644
index 000000000..4275e9367
--- /dev/null
+++ b/addon/services/inspection-form-actions.js
@@ -0,0 +1,196 @@
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { action, set } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard';
+import { normalizeFieldGroups, serializeFieldGroups } from '../utils/inspection-form-structure';
+
+/** A link's life when nobody chooses; the server applies the same when left blank. */
+const DEFAULT_LINK_TTL_HOURS = 72;
+
+/** A date as a `datetime-local` input reads it: local time, to the minute. */
+export function toDatetimeLocal(date) {
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+/** The default expiry, filled into the form so the dispatcher can see it. */
+function defaultLinkExpiry() {
+ return toDatetimeLocal(new Date(Date.now() + DEFAULT_LINK_TTL_HOURS * 60 * 60 * 1000));
+}
+
+export default class InspectionFormActionsService extends ResourceActionService {
+ @service fetch;
+ @service notifications;
+ @service intl;
+
+ /**
+ * When a public link was last generated. Every open link list watches
+ * this — the one inside the generate modal and the one on the form's
+ * details panel — so a new link appears in both without a reload.
+ */
+ @tracked linksChangedAt = 0;
+
+ constructor() {
+ super(...arguments);
+ this.initialize('inspection-form', {
+ defaultAttributes: {
+ type: 'dvir',
+ status: 'draft',
+ items: [],
+ settings: {
+ require_signature: true,
+ create_issue_on_failure: true,
+ create_work_order_on_failure: false,
+ },
+ },
+ });
+ }
+
+ transition = {
+ view: (form) => this.transitionTo('maintenance.inspection-forms.index.details', form),
+ edit: (form) => this.transitionTo('maintenance.inspection-forms.index.edit', form),
+ create: () => this.transitionTo('maintenance.inspection-forms.index.new'),
+ };
+
+ /**
+ * A form's structure — its field groups and their fields.
+ *
+ * The `inspection-form` model belongs to `@fleetbase/fleetops-data` and
+ * declares no attribute for the structure, so Ember Data drops it on the
+ * way in and on the way out. Both directions go through the internal
+ * endpoint directly instead: a read carries `field_groups` beside a flat
+ * `fields` list, and a write posts the whole thing back under
+ * `inspection_form.field_groups`, which is the key
+ * `InspectionFormController::syncStructureFromRequest()` reads.
+ */
+ async loadStructure(form) {
+ if (!form?.id) {
+ return [];
+ }
+
+ const response = await this.fetch.get(`inspection-forms/${form.id}`);
+
+ return normalizeFieldGroups(response?.inspection_form ?? response?.inspectionForm ?? response);
+ }
+
+ /**
+ * Writes the whole structure. The builder always posts every group and
+ * every field, so the server prunes what the post no longer lists — a
+ * field the post dropped is a field the author deleted.
+ */
+ async saveStructure(form, groups) {
+ if (!form?.id || !Array.isArray(groups) || groups.length === 0) {
+ return null;
+ }
+
+ return this.fetch.put(`inspection-forms/${form.id}`, {
+ inspection_form: { field_groups: serializeFieldGroups(groups) },
+ });
+ }
+
+ @action async publish(form) {
+ try {
+ await this.fetch.post(`inspection-forms/${form.id}/publish`);
+ this.notifications.success('Inspection form published.');
+ await this.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action async archive(form) {
+ try {
+ await this.fetch.post(`inspection-forms/${form.id}/archive`);
+ this.notifications.success('Inspection form archived.');
+ await this.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ /**
+ * Say whether a PIN went out, when one was asked to be sent. A delivery
+ * that failed is a warning and not an error: the link exists either way,
+ * and its PIN is on screen to share by hand.
+ */
+ notifyPinDelivery(delivery) {
+ if (!delivery) {
+ this.notifications.success(this.intl.t('inspection.link.generated-toast'));
+ return;
+ }
+
+ if (delivery.sent) {
+ this.notifications.success(this.intl.t(`inspection.link.pin-sent-${delivery.via}`, { to: delivery.to }));
+ return;
+ }
+
+ this.notifications.warning(this.intl.t('inspection.link.pin-not-sent', { reason: delivery.error }));
+ }
+
+ @action generateLink(form) {
+ if (form.status !== 'published') {
+ this.notifications.warning(this.intl.t('inspection.link.publish-first'));
+ return;
+ }
+
+ // Who the link is for, and the driver and vehicle being inspected, are
+ // each optional: anyone in the organisation may complete an inspection.
+ const formState = {
+ assignee: null,
+ driver: null,
+ vehicle: null,
+ expires_at: defaultLinkExpiry(),
+ pin_delivery: 'none',
+ generated: null,
+ };
+
+ return this.modalsManager.show('modals/inspection-link', {
+ title: 'Generate Inspection Link',
+ acceptButtonText: 'Generate Link',
+ acceptButtonIcon: 'link',
+ declineButtonText: 'Close',
+ form,
+ formState,
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ const response = await this.fetch.post(`inspection-forms/${form.id}/generate-link`, {
+ assignee: formState.assignee?.id,
+ driver: formState.driver?.id,
+ vehicle: formState.vehicle?.id,
+ // The input holds local time with no zone; sent as it was,
+ // the server read it as its own zone and the link expired
+ // hours early or late. Sent as an instant, it means what
+ // the dispatcher picked.
+ expires_at: formState.expires_at ? new Date(formState.expires_at).toISOString() : null,
+ single_use: true,
+ pin_delivery: formState.pin_delivery,
+ });
+ const link = response?.link;
+ const url = link?.path ? `${window.location.origin}${link.path}` : null;
+
+ // Shown in the modal until it closes: the link, and the PIN to
+ // share with it by some other way.
+ set(formState, 'generated', { url, pin: link?.pin ?? null });
+
+ // Every open link list watches this and reloads, so the link
+ // that was just minted appears to be read, copied again or
+ // revoked — rather than living only in the clipboard.
+ this.linksChangedAt = Date.now();
+
+ if (url) {
+ await copyToClipboard(url);
+ }
+
+ this.notifyPinDelivery(response?.pin_delivery);
+
+ modal.stopLoading();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+}
diff --git a/addon/services/inspection-submission-actions.js b/addon/services/inspection-submission-actions.js
new file mode 100644
index 000000000..4427569a0
--- /dev/null
+++ b/addon/services/inspection-submission-actions.js
@@ -0,0 +1,101 @@
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+
+export default class InspectionSubmissionActionsService extends ResourceActionService {
+ @service fetch;
+ @service notifications;
+
+ constructor() {
+ super(...arguments);
+ this.initialize('inspection-submission', {
+ defaultAttributes: {
+ type: 'dvir',
+ status: 'draft',
+ source: 'console',
+ item_results: [],
+ },
+ });
+ }
+
+ transition = {
+ view: (submission) => this.transitionTo('maintenance.inspection-submissions.index.details', submission),
+ edit: (submission) => this.transitionTo('maintenance.inspection-submissions.index.edit', submission),
+ create: () => this.transitionTo('maintenance.inspection-submissions.index.new'),
+ };
+
+ /**
+ * The answers filed against a submission, as the resource projects them:
+ * every value beside the field it answers, with `file:
` references
+ * resolved to something fetchable.
+ *
+ * The `inspection-submission` model belongs to `@fleetbase/fleetops-data`
+ * and declares no `custom_field_values` relationship, so Ember Data drops
+ * the projection; this reads it from the internal payload directly.
+ *
+ * @return {Object} the answers keyed by the field uuid they answer
+ */
+ async loadAnswers(submission) {
+ if (!submission?.id) {
+ return {};
+ }
+
+ const response = await this.fetch.get(`inspection-submissions/${submission.id}`);
+ const record = response?.inspection_submission ?? response?.inspectionSubmission ?? response;
+ const values = Array.isArray(record?.custom_field_values) ? record.custom_field_values : [];
+
+ return values.reduce((carry, value) => {
+ const key = value?.custom_field;
+ if (key) {
+ carry[key] = value.value;
+ }
+
+ return carry;
+ }, {});
+ }
+
+ /**
+ * Writes the answers. `inspection_submission.custom_field_values` is what
+ * `InspectionSubmissionController::syncAnswersFromRequest()` reads, and it
+ * is the same body the driver API accepts — the console and the app write
+ * the same rows, and the server derives the item results from the
+ * pass-fail answers among them.
+ *
+ * @param {Array} rows [{ custom_field, value, value_type }]
+ */
+ async saveAnswers(submission, rows) {
+ if (!submission?.id || !Array.isArray(rows) || rows.length === 0) {
+ return null;
+ }
+
+ return this.fetch.put(`inspection-submissions/${submission.id}`, {
+ inspection_submission: { custom_field_values: rows },
+ });
+ }
+
+ @action async submit(submission) {
+ return this.postAction(submission, 'submit', 'Inspection submitted.');
+ }
+
+ @action async createIssue(submission) {
+ return this.postAction(submission, 'create-issue', 'Issue created from failed inspection items.');
+ }
+
+ @action async createWorkOrder(submission) {
+ return this.postAction(submission, 'create-work-order', 'Work order created from failed inspection items.');
+ }
+
+ @action async resolve(submission) {
+ return this.postAction(submission, 'resolve', 'Inspection resolved.');
+ }
+
+ async postAction(submission, actionName, message) {
+ try {
+ await this.fetch.post(`inspection-submissions/${submission.id}/${actionName}`);
+ this.notifications.success(message);
+ await this.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+}
diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css
index dbc585215..5958dd48a 100644
--- a/addon/styles/fleetops-engine.css
+++ b/addon/styles/fleetops-engine.css
@@ -8883,3 +8883,1592 @@ body[data-theme='dark'] .filter-multi-model > .clear-button {
height: 1.625rem;
min-width: 4rem;
}
+
+/* ==========================================================================
+ Inspection sheet — "Promotion"
+ --------------------------------------------------------------------------
+ The author's grid survives, but only for fields that stay compact. The
+ moment a field needs room — a pass-fail that failed and now owes a
+ severity, a comment and photos, or a note, upload or signature that never
+ fitted a column — it is promoted out of the grid into a full-width band at
+ the end of its group. Nothing stretches its neighbour, because after
+ promotion it has no neighbour.
+
+ Each group header carries one dot per field, so an inspector can see at a
+ glance what is still open without reading a label.
+
+ Rendered identically by the console's submission form, the read-only
+ record, and the public link a driver opens on a phone.
+ ========================================================================== */
+
+.inspection-sheet,
+.inspection-flyout {
+ --ins-bg: #fff;
+ --ins-bg-sunken: #f9fafb;
+ --ins-border: #e5e7eb;
+ --ins-border-strong: #d1d5db;
+ --ins-text: #111827;
+ --ins-text-soft: #374151;
+ --ins-text-muted: #6b7280;
+ --ins-text-faint: #9ca3af;
+ --ins-pass: #16a34a;
+ --ins-fail: #dc2626;
+ --ins-fail-text: #b91c1c;
+ --ins-fail-edge: #fecaca;
+ --ins-fail-fill: #fef2f2;
+ --ins-fail-fill-strong: #fee2e2;
+ --ins-fail-field: #fff;
+ --ins-fail-placeholder: #f87171;
+ --ins-fail-ring: rgb(220 38 38 / 22%);
+ --ins-fail-hatch-a: #fecaca;
+ --ins-fail-hatch-b: #fee2e2;
+ --ins-na: #6b7280;
+ --ins-warn: #d97706;
+ --ins-warn-text: #92400e;
+ --ins-warn-strong: #b45309;
+ --ins-hatch-a: #e5e7eb;
+ --ins-hatch-b: #f3f4f6;
+ --ins-mono: ui-monospace, 'IBM Plex Mono', sfmono-regular, menlo, monaco, consolas, monospace;
+}
+
+.inspection-sheet {
+ /* The floating flyouts are positioned against the sheet. */
+ position: relative;
+ display: flex;
+ flex-direction: column;
+
+ /*
+ * The sheet is measured, not the window. It renders in a ~600px overlay
+ * panel on a wide screen and full width on a phone, so a viewport media
+ * query would collapse the author's columns in exactly the wrong places.
+ */
+ container-type: inline-size;
+}
+
+body[data-theme='dark'] .inspection-sheet,
+body[data-theme='dark'] .inspection-flyout {
+ --ins-bg: #1f2937;
+ --ins-bg-sunken: #1f2937;
+ --ins-border: #374151;
+ --ins-border-strong: #4b5563;
+ --ins-text: #f9fafb;
+ --ins-text-soft: #e5e7eb;
+ --ins-text-muted: #9ca3af;
+ --ins-text-faint: #6b7280;
+ --ins-pass: #16a34a;
+ --ins-fail: #dc2626;
+ --ins-fail-text: #fca5a5;
+ --ins-fail-edge: #4c2326;
+ --ins-fail-fill: #241b1e;
+ --ins-fail-fill-strong: #3f1d1d;
+ --ins-fail-field: #1b1416;
+ --ins-fail-placeholder: #a86b6e;
+ --ins-fail-ring: rgb(239 68 68 / 28%);
+ --ins-fail-hatch-a: #3b2326;
+ --ins-fail-hatch-b: #2c1b1e;
+ --ins-na: #9ca3af;
+ --ins-warn: #b45309;
+ --ins-warn-text: #fcd34d;
+ --ins-warn-strong: #fbbf24;
+ --ins-hatch-a: #374151;
+ --ins-hatch-b: #2b3644;
+}
+
+/* The sheet is one card, not one card per group. */
+.inspection-sheet__body {
+ border: 1px solid var(--ins-border);
+ border-radius: 0.5rem;
+ background-color: var(--ins-bg);
+ box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
+ overflow: hidden;
+}
+
+body[data-theme='dark'] .inspection-sheet__body {
+ box-shadow: 0 1px 3px rgb(0 0 0 / 35%);
+}
+
+.inspection-sheet__groups {
+ padding: 1rem;
+}
+
+/* --- group header ------------------------------------------------------- */
+
+.inspection-group__header {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding-bottom: 0.5625rem;
+ border-bottom: 1px solid var(--ins-border);
+}
+
+.inspection-group + .inspection-group {
+ margin-top: 1.75rem;
+}
+
+.inspection-group__name {
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ins-text-soft);
+ min-width: 0;
+}
+
+/* One dot per field: what is answered, what failed, what is still open. */
+.inspection-group__dots {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ flex-shrink: 0;
+}
+
+.inspection-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ box-sizing: border-box;
+ background-color: var(--ins-text-faint);
+}
+
+.inspection-dot[data-marker='pass'] {
+ background-color: var(--ins-pass);
+}
+
+.inspection-dot[data-marker='fail'] {
+ background-color: var(--ins-fail);
+}
+
+.inspection-dot[data-marker='na'] {
+ background-color: var(--ins-na);
+}
+
+.inspection-dot[data-marker='outstanding'] {
+ background-color: var(--ins-warn-strong);
+}
+
+.inspection-dot[data-marker='empty'] {
+ background-color: transparent;
+ border: 1px solid var(--ins-text-faint);
+}
+
+.inspection-group__meta {
+ margin-left: auto;
+ flex-shrink: 0;
+ font-family: var(--ins-mono);
+ font-size: 0.6875rem;
+ line-height: 1;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: var(--ins-text-faint);
+ white-space: nowrap;
+}
+
+.inspection-group__meta[data-outstanding='true'] {
+ color: var(--ins-warn-strong);
+}
+
+.inspection-group__description {
+ margin: 0.5rem 0 0;
+ font-size: 0.75rem;
+ line-height: 1.45;
+ color: var(--ins-text-muted);
+}
+
+.inspection-group__empty {
+ padding: 0.75rem 0;
+ font-size: 0.75rem;
+ color: var(--ins-text-muted);
+}
+
+/* --- the author's grid, for fields that stay compact -------------------- */
+
+.inspection-group__grid {
+ display: grid;
+ gap: 1rem 0.875rem;
+ align-items: start;
+ padding-top: 0.875rem;
+}
+
+.inspection-group__grid > .inspection-band {
+ grid-column: 1 / -1;
+}
+
+.inspection-group__grid[data-columns='1'] {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.inspection-group__grid[data-columns='2'] {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.inspection-group__grid[data-columns='3'] {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.inspection-group__grid[data-columns='4'] {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+/* A narrow sheet cannot hold the wider authored grids. */
+@container (width <= 660px) {
+ .inspection-group__grid[data-columns='3'],
+ .inspection-group__grid[data-columns='4'] {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+/*
+ * A phone-width sheet stacks every grid. Matched to the `[data-columns]`
+ * rules' specificity: as a bare class it lost to them, and an authored two- or
+ * three-column group stayed in two 160px columns on a phone.
+ */
+@container (width <= 420px) {
+ .inspection-group__grid[data-columns] {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* --- a compact field --------------------------------------------------- */
+
+.inspection-cell {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.inspection-cell__label {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.3;
+ color: var(--ins-text);
+}
+
+.inspection-required {
+ color: var(--ins-fail);
+ margin-left: 0.125rem;
+}
+
+.inspection-cell__hint {
+ font-size: 0.75rem;
+ line-height: 1.4;
+ color: var(--ins-text-muted);
+ white-space: pre-wrap;
+}
+
+.inspection-cell__control {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ width: 100%;
+}
+
+.inspection-cell__control > .fleetbase-model-select,
+.inspection-cell__control > .ember-basic-dropdown {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.inspection-unit {
+ flex-shrink: 0;
+ font-family: var(--ins-mono);
+ font-size: 0.75rem;
+ color: var(--ins-text-muted);
+}
+
+.inspection-note {
+ font-size: 0.75rem;
+ color: var(--ins-text-faint);
+}
+
+/* A required answer that is still missing says so on its own edge. */
+.inspection-input--outstanding.form-input,
+.inspection-input--outstanding .ember-power-select-trigger {
+ border-color: var(--ins-warn);
+}
+
+/* --- the pass / fail / n-a and severity segmented controls -------------- */
+
+.inspection-choice {
+ display: flex;
+ align-items: stretch;
+ min-height: 2.25rem;
+ border: 1px solid var(--ins-border);
+ border-radius: 0.375rem;
+ overflow: hidden;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+/* On a promoted band the control is pushed to the right at its natural size. */
+.inspection-choice--auto {
+ flex: 0 0 auto;
+ margin-left: auto;
+}
+
+.inspection-choice__option {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.375rem 0.5rem;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ line-height: 1;
+ color: var(--ins-text-muted);
+ cursor: pointer;
+ white-space: nowrap;
+ transition:
+ background-color 0.12s ease,
+ color 0.12s ease;
+}
+
+.inspection-choice--auto .inspection-choice__option {
+ flex: 0 0 auto;
+ padding: 0 0.75rem;
+}
+
+.inspection-choice__option + .inspection-choice__option {
+ border-left: 1px solid var(--ins-border);
+}
+
+.inspection-choice__option:hover:not(:disabled) {
+ color: var(--ins-text);
+}
+
+.inspection-choice__option:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: -2px;
+}
+
+.inspection-choice__option:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.inspection-choice__option[aria-pressed='true'] {
+ font-weight: 600;
+ color: #fff;
+ background-color: var(--ins-na);
+}
+
+.inspection-choice__option[aria-pressed='true'][data-answer='pass'] {
+ background-color: var(--ins-pass);
+}
+
+.inspection-choice__option[aria-pressed='true'][data-answer='fail'],
+.inspection-choice__option[aria-pressed='true'][data-answer='severity'] {
+ background-color: var(--ins-fail);
+}
+
+/* --- a promoted band ---------------------------------------------------- */
+
+.inspection-band {
+ border: 1px solid var(--ins-border);
+ border-radius: 0.375rem;
+ overflow: hidden;
+}
+
+.inspection-band__head {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.6875rem 0.75rem;
+}
+
+.inspection-band__label {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.3;
+ color: var(--ins-text);
+ min-width: 0;
+}
+
+.inspection-band__aside {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.5625rem;
+ flex-shrink: 0;
+}
+
+.inspection-band__body {
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+}
+
+.inspection-band--stacked .inspection-band__body {
+ padding-top: 0;
+}
+
+/* A failure keeps the same band, in red, with its own detail below. */
+.inspection-band--defect {
+ border-color: var(--ins-fail);
+ background-color: var(--ins-fail-fill);
+}
+
+.inspection-band--defect .inspection-band__head {
+ border-bottom: 1px solid var(--ins-fail-edge);
+ padding-right: 0.75rem;
+}
+
+.inspection-chip {
+ flex-shrink: 0;
+ font-family: var(--ins-mono);
+ font-size: 0.625rem;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ins-fail-text);
+ border: 1px solid var(--ins-fail-edge);
+ border-radius: 0.1875rem;
+ padding: 0.25rem 0.3125rem;
+}
+
+.inspection-band--defect .form-input {
+ border-color: var(--ins-fail-edge);
+ background-color: transparent;
+}
+
+.inspection-band--defect .form-input:focus {
+ border-color: var(--ins-fail);
+}
+
+.inspection-band--defect .inspection-choice,
+.inspection-band--defect .inspection-slot {
+ border-color: var(--ins-fail-edge);
+}
+
+.inspection-defect__row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.625rem;
+ align-items: center;
+}
+
+@container (width <= 560px) {
+ .inspection-defect__row {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* The unsafe switch is a pill, not a bare toggle: it is the gravest thing here. */
+.inspection-unsafe {
+ display: flex;
+ align-items: center;
+ gap: 0.4375rem;
+ min-height: 2.25rem;
+ padding: 0 0.625rem;
+ border: 1px solid var(--ins-fail);
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--ins-fail-text);
+ white-space: nowrap;
+}
+
+.inspection-unsafe[data-on='true'] {
+ background-color: var(--ins-fail-fill-strong);
+}
+
+/* --- photo slots -------------------------------------------------------- */
+
+.inspection-slots {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.inspection-slot {
+ display: block;
+ position: relative;
+ width: 60px;
+ height: 44px;
+ border-radius: 0.3125rem;
+ border: 1px solid var(--ins-border);
+ overflow: hidden;
+ flex-shrink: 0;
+ background: repeating-linear-gradient(45deg, var(--ins-hatch-a) 0 6px, var(--ins-hatch-b) 6px 12px);
+}
+
+.inspection-slot img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.inspection-slot__icon {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ color: var(--ins-text-faint);
+}
+
+.inspection-slot__remove {
+ position: absolute;
+ top: 0;
+ right: 0;
+}
+
+/* The empty slot invites the next photo rather than sitting as a button. */
+.inspection-slot--add {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px dashed var(--ins-border-strong);
+ background: none;
+ cursor: pointer;
+ font-size: 1.125rem;
+ line-height: 1;
+ color: var(--ins-text-faint);
+ padding: 0;
+ text-decoration: none;
+}
+
+.inspection-slot--add:hover {
+ color: var(--ins-text);
+ border-color: var(--ins-text-faint);
+}
+
+.inspection-slots__note {
+ margin-left: auto;
+ text-align: right;
+ font-family: var(--ins-mono);
+ font-size: 0.6875rem;
+ line-height: 1.4;
+ text-transform: uppercase;
+ color: var(--ins-text-faint);
+}
+
+/* --- the foot ----------------------------------------------------------- */
+
+.inspection-foot {
+ border-top: 1px solid var(--ins-border);
+ background-color: var(--ins-bg-sunken);
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+}
+
+.inspection-tallies {
+ display: flex;
+ align-items: stretch;
+ gap: 0.5rem;
+}
+
+.inspection-tally {
+ flex: 1 1 0;
+ min-width: 0;
+ background-color: var(--ins-bg);
+ border: 1px solid var(--ins-border);
+ border-left: 3px solid var(--ins-na);
+ border-radius: 0.375rem;
+ padding: 0.625rem 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3125rem;
+}
+
+.inspection-tally[data-kind='passed'] {
+ border-left-color: var(--ins-pass);
+}
+
+.inspection-tally[data-kind='failed'] {
+ border-left-color: var(--ins-fail);
+}
+
+.inspection-tally[data-kind='outstanding'] {
+ border-left-color: var(--ins-warn);
+}
+
+.inspection-tally[data-kind='outstanding'][data-any='true'] {
+ border-color: var(--ins-warn);
+ border-left-color: var(--ins-warn);
+}
+
+.inspection-tally__value {
+ font-size: 1.25rem;
+ font-weight: 700;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+ color: var(--ins-text);
+}
+
+.inspection-tally[data-kind='outstanding'][data-any='true'] .inspection-tally__value {
+ color: var(--ins-warn-text);
+}
+
+.inspection-tally__label {
+ font-size: 0.6875rem;
+ font-weight: 500;
+ line-height: 1;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ins-text-muted);
+}
+
+/*
+ * On a phone, four tallies in a row leave "Outstanding" no room: two by two.
+ * A grid rather than wrapping flex items at half width, which only pairs up
+ * under border-box sizing; with padding added to the basis they stack one high.
+ */
+@container (width <= 420px) {
+ .inspection-tallies {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.inspection-tally[data-kind='outstanding'][data-any='true'] .inspection-tally__label {
+ color: var(--ins-warn-strong);
+}
+
+.inspection-banner {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.6875rem 0.75rem;
+ border: 1px solid var(--ins-warn);
+ border-radius: 0.375rem;
+ background-color: var(--ins-bg);
+ font-size: 0.75rem;
+ line-height: 1.4;
+ color: var(--ins-warn-text);
+}
+
+.inspection-banner--unsafe {
+ border-color: var(--ins-fail);
+ background-color: var(--ins-fail-fill);
+ color: var(--ins-fail-text);
+}
+
+body[data-theme='dark'] .inspection-banner--unsafe {
+ background-color: #3f1d1d;
+}
+
+.inspection-banner__chip {
+ flex-shrink: 0;
+ font-family: var(--ins-mono);
+ font-size: 0.6875rem;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #fff;
+ background-color: var(--ins-fail);
+ border-radius: 0.25rem;
+ padding: 0.3125rem 0.4375rem;
+}
+
+.inspection-banner__jump {
+ appearance: none;
+ border: 0;
+ background: none;
+ margin-left: auto;
+ flex-shrink: 0;
+ padding: 0;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.inspection-banner__jump:hover {
+ text-decoration: underline;
+}
+
+/* A jumped-to field is held for a moment so the eye can find it. */
+.inspection-cell--targeted,
+.inspection-band--targeted {
+ animation: inspection-target 1.6s ease-out;
+}
+
+@keyframes inspection-target {
+ 0%,
+ 60% {
+ box-shadow: 0 0 0 2px var(--ins-warn);
+ }
+
+ 100% {
+ box-shadow: 0 0 0 2px transparent;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .inspection-cell--targeted,
+ .inspection-band--targeted {
+ animation: none;
+ box-shadow: 0 0 0 2px var(--ins-warn);
+ }
+}
+
+/*
+ * The sheet sits inside an overlay panel that has its own edge. Without an
+ * inset its borders land on that edge and read as a double rule.
+ */
+.inspection-sheet-inset {
+ padding: 0.75rem;
+}
+
+/* ==========================================================================
+ Inspection links
+ --------------------------------------------------------------------------
+ A generated link used to exist only as a toast. These are the rows that
+ replaced it: one per link, with the link itself, who and what it was for,
+ and whether it still works.
+ ========================================================================== */
+
+.inspection-link-list {
+ --inspection-border: #e5e7eb;
+ --inspection-surface: #fff;
+ --inspection-surface-sunken: #f9fafb;
+ --inspection-text: #111827;
+ --inspection-text-muted: #6b7280;
+}
+
+body[data-theme='dark'] .inspection-link-list {
+ --inspection-border: #374151;
+ --inspection-surface: #1f2937;
+ --inspection-surface-sunken: #1f2937;
+ --inspection-text: #f9fafb;
+ --inspection-text-muted: #9ca3af;
+}
+
+.inspection-link-list__empty {
+ padding: 0.75rem;
+ font-size: 0.75rem;
+ color: var(--inspection-text-muted);
+}
+
+/* The modal shows the list inside its own bordered block. */
+.inspection-link-panel {
+ border: 1px solid var(--inspection-border, #e5e7eb);
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+body[data-theme='dark'] .inspection-link-panel {
+ border-color: #374151;
+}
+
+.inspection-link-panel__header {
+ padding: 0.5rem 0.625rem;
+ border-bottom: 1px solid var(--inspection-border, #e5e7eb);
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #374151;
+}
+
+body[data-theme='dark'] .inspection-link-panel__header {
+ border-bottom-color: #374151;
+ color: #e5e7eb;
+}
+
+.inspection-link {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ padding: 0.5rem 0.625rem;
+}
+
+.inspection-link + .inspection-link {
+ border-top: 1px solid var(--inspection-border);
+}
+
+/* A link that can no longer be used recedes rather than disappearing. */
+.inspection-link:not([data-state='active']) {
+ opacity: 0.6;
+}
+
+.inspection-link__head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.inspection-link__state {
+ flex-shrink: 0;
+ border-radius: 9999px;
+ padding: 0.125rem 0.5rem;
+ font-size: 0.625rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ background-color: #e5e7eb;
+ color: #374151;
+}
+
+.inspection-link__state[data-state='active'] {
+ background-color: #16a34a;
+ color: #fff;
+}
+
+.inspection-link__state[data-state='expired'],
+.inspection-link__state[data-state='revoked'] {
+ background-color: #dc2626;
+ color: #fff;
+}
+
+.inspection-link__state[data-state='used'] {
+ background-color: #2563eb;
+ color: #fff;
+}
+
+/* Locked after too many wrong PINs: stopped, but by a guesser, not by anyone here. */
+.inspection-link__state[data-state='locked'] {
+ background-color: #d97706;
+ color: #fff;
+}
+
+.inspection-link__for {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--inspection-text);
+ min-width: 0;
+}
+
+.inspection-link__when {
+ margin-left: auto;
+ flex-shrink: 0;
+ font-size: 0.6875rem;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__url {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ border: 1px solid var(--inspection-border);
+ border-radius: 0.375rem;
+ background-color: var(--inspection-surface);
+ box-shadow: inset 0 1px 2px rgb(0 0 0 / 6%);
+ padding: 0.25rem 0.375rem;
+}
+
+body[data-theme='dark'] .inspection-link__url {
+ box-shadow: inset 0 1px 2px rgb(0 0 0 / 25%);
+}
+
+.inspection-link__code {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-x: auto;
+ white-space: nowrap;
+ font-size: 0.6875rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__note {
+ font-size: 0.6875rem;
+ font-style: italic;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__foot {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.6875rem;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__sep {
+ padding: 0 0.125rem;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__warn {
+ color: #b45309;
+}
+
+body[data-theme='dark'] .inspection-link__warn {
+ color: #fbbf24;
+}
+
+/* The PIN that goes with a link, with ways to copy it or send it again. */
+.inspection-link__pin {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.6875rem;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__pin-label {
+ font-size: 0.625rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.inspection-link__pin-code {
+ margin-right: 0.25rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.875rem;
+ font-weight: 700;
+ letter-spacing: 0.2em;
+ color: var(--inspection-text, #111827);
+}
+
+body[data-theme='dark'] .inspection-link__pin-code {
+ color: #f9fafb;
+}
+
+/* The PIN asked for before a public link shows its form: large, spaced, easy to type on a phone. */
+.public-inspection-pin input.public-inspection-pin__input.form-input {
+ height: auto;
+ padding: 0.625rem 0.75rem;
+ text-align: center;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 1.5rem;
+ letter-spacing: 0.5em;
+ text-indent: 0.5em;
+}
+
+/* On a phone the submit button spans the page, where a thumb can reach it. */
+@media (width <= 639px) {
+ .public-inspection-submit__button,
+ .public-inspection-submit__button > button {
+ width: 100%;
+ justify-content: center;
+ }
+}
+
+/*
+ * A select option: photo, name, and a line of detail. Colours are inherited
+ * rather than set, so an option reads correctly on the dropdown's highlighted
+ * row and in dark mode without rules of its own for either.
+ */
+.select-option {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ min-width: 0;
+ padding: 0.125rem 0;
+}
+
+.select-option__photo {
+ flex-shrink: 0;
+ width: 2rem;
+ height: 2rem;
+ object-fit: cover;
+ border-radius: 9999px;
+ background-color: rgb(156 163 175 / 20%);
+}
+
+.select-option__photo[data-shape='square'] {
+ border-radius: 0.375rem;
+}
+
+.select-option__text {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 0.125rem;
+ min-width: 0;
+}
+
+.select-option__title,
+.select-option__details {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.select-option__title {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.2;
+}
+
+.select-option__details {
+ font-size: 0.6875rem;
+ line-height: 1.2;
+ opacity: 0.7;
+}
+
+/* In a closed select there is room for one line: a small photo, the name, then the detail. */
+.select-option--compact {
+ gap: 0.5rem;
+ padding: 0;
+}
+
+.select-option--compact .select-option__photo {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+.select-option--compact .select-option__text {
+ flex-direction: row;
+ align-items: baseline;
+ gap: 0.375rem;
+}
+
+.select-option--compact .select-option__title {
+ flex-shrink: 0;
+ max-width: 60%;
+ font-weight: 500;
+}
+
+/* A link just generated, shown above the form until the modal closes. */
+.inspection-link-generated {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ border: 1px solid #bbf7d0;
+ border-radius: 0.5rem;
+ background-color: #f0fdf4;
+ padding: 0.625rem;
+}
+
+body[data-theme='dark'] .inspection-link-generated {
+ border-color: #166534;
+ background-color: rgb(22 101 52 / 20%);
+}
+
+.inspection-link-generated__title {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: #14532d;
+}
+
+body[data-theme='dark'] .inspection-link-generated__title {
+ color: #bbf7d0;
+}
+
+.inspection-link-generated__help {
+ margin: 0;
+ font-size: 0.6875rem;
+ color: #166534;
+}
+
+body[data-theme='dark'] .inspection-link-generated__help {
+ color: #86efac;
+}
+
+.inspection-link-generated .inspection-link__url {
+ background-color: #fff;
+}
+
+body[data-theme='dark'] .inspection-link-generated .inspection-link__url {
+ background-color: #1f2937;
+ border-color: #374151;
+}
+
+/* A stored answer, read back: the same row, with the value where the control was. */
+.inspection-row__answer {
+ font-size: 0.8125rem;
+ color: var(--inspection-text);
+}
+
+/* A stored answer, read back: the value sits where the control was. */
+.inspection-answer {
+ margin: 0;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ color: var(--ins-text-soft);
+ white-space: pre-wrap;
+ min-width: 0;
+}
+
+/* ==========================================================================
+ Defect flyout
+ --------------------------------------------------------------------------
+ A failed check keeps its cell. Its severity, unsafe flag, comment and
+ photos open in a panel anchored to that cell, so failing a check can never
+ change the layout of the sheet. Closing it leaves a chip in the cell, and
+ the defects tray at the foot keeps the record.
+ ========================================================================== */
+
+/* The open field is ringed with an outline, which takes no space: opening a
+ flyout must not move anything either. */
+.inspection-cell--flyout-open {
+ outline: 2px solid var(--ins-fail);
+ outline-offset: 6px;
+ border-radius: 0.375rem;
+}
+
+/* --- the chip a closed failure leaves in its cell ----------------------- */
+
+.inspection-defect-chip {
+ appearance: none;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem 0.5rem;
+ width: 100%;
+ padding: 0.4375rem 0.625rem;
+ border: 1px solid var(--ins-fail-edge);
+ border-left: 3px solid var(--ins-fail);
+ border-radius: 0.375rem;
+ background-color: var(--ins-fail-fill);
+ color: var(--ins-fail-text);
+ font-size: 0.75rem;
+ line-height: 1.3;
+ text-align: left;
+ cursor: pointer;
+}
+
+.inspection-defect-chip:hover:not(:disabled) {
+ border-color: var(--ins-fail);
+}
+
+.inspection-defect-chip:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: 2px;
+}
+
+.inspection-defect-chip:disabled {
+ cursor: default;
+}
+
+.inspection-defect-chip.is-incomplete {
+ border-left-color: var(--ins-warn);
+}
+
+.inspection-defect-chip__severity {
+ font-size: 0.6875rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.inspection-defect-chip__unsafe {
+ font-family: var(--ins-mono);
+ font-size: 0.625rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #fff;
+ background-color: var(--ins-fail);
+ border-radius: 0.1875rem;
+ padding: 0.1875rem 0.3125rem;
+}
+
+.inspection-defect-chip__meta {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ color: var(--ins-text-muted);
+}
+
+.inspection-defect-chip__status {
+ font-weight: 600;
+ color: var(--ins-warn-strong);
+}
+
+.inspection-defect-chip__edit {
+ margin-left: auto;
+ color: var(--ins-text-muted);
+}
+
+/* --- the flyout -------------------------------------------------------- */
+
+.inspection-flyout {
+ display: flex;
+ flex-direction: column;
+ background-color: var(--ins-fail-fill);
+ color: var(--ins-text);
+ border: 1px solid var(--ins-fail);
+ border-radius: 0.5rem;
+ box-shadow:
+ 0 12px 32px rgb(0 0 0 / 18%),
+ 0 2px 6px rgb(0 0 0 / 8%);
+ outline: none;
+}
+
+body[data-theme='dark'] .inspection-flyout {
+ box-shadow:
+ 0 16px 40px rgb(0 0 0 / 55%),
+ 0 2px 6px rgb(0 0 0 / 30%);
+}
+
+.inspection-flyout--floating {
+ position: absolute;
+ z-index: 40;
+ width: min(30rem, calc(100% - 1rem));
+ animation: inspection-flyout-in 0.14s ease-out;
+}
+
+.inspection-flyout--floating[data-placement='top'] {
+ animation-name: inspection-flyout-in-above;
+}
+
+/*
+ * Room for the reveal's margin at the very end of the page. An absolutely
+ * placed box extends the scroll area, but its margin does not, so without this
+ * a flyout opened below the last field could only ever sit flush against the
+ * bottom edge of the panel.
+ */
+.inspection-flyout--floating::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: -0.5rem;
+ height: 0.5rem;
+ pointer-events: none;
+}
+
+/* A caret points at the Fail button of the field it belongs to. */
+.inspection-flyout__caret {
+ position: absolute;
+ left: var(--flyout-caret-x, 1.5rem);
+ width: 12px;
+ height: 12px;
+ background-color: var(--ins-fail-fill);
+ border: 1px solid var(--ins-fail);
+ transform: translateX(-50%) rotate(45deg);
+ pointer-events: none;
+}
+
+.inspection-flyout[data-placement='bottom'] .inspection-flyout__caret {
+ top: -7px;
+ border-right: 0;
+ border-bottom: 0;
+}
+
+.inspection-flyout[data-placement='top'] .inspection-flyout__caret {
+ bottom: -7px;
+ border-top: 0;
+ border-left: 0;
+}
+
+.inspection-flyout__head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.625rem 0.75rem;
+ border-bottom: 1px solid var(--ins-fail-edge);
+}
+
+.inspection-flyout__title {
+ min-width: 0;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.inspection-flyout__close {
+ appearance: none;
+ border: 0;
+ background: none;
+ margin-left: auto;
+ padding: 0.25rem 0.375rem;
+ border-radius: 0.25rem;
+ color: var(--ins-text-muted);
+ cursor: pointer;
+}
+
+.inspection-flyout__close:hover {
+ color: var(--ins-text);
+}
+
+.inspection-flyout__close:focus-visible {
+ outline: 2px solid #2563eb;
+}
+
+.inspection-flyout__body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+ padding: 0.75rem;
+ overflow-y: auto;
+}
+
+.inspection-flyout__foot {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ padding: 0.625rem 0.75rem;
+ border-top: 1px solid var(--ins-fail-edge);
+}
+
+.inspection-flyout__note {
+ margin-right: auto;
+ font-family: var(--ins-mono);
+ font-size: 0.6875rem;
+ line-height: 1.4;
+ text-transform: uppercase;
+ color: var(--ins-warn-strong);
+}
+
+/* Inside a failure, every control takes the failure's colours. */
+.inspection-flyout .inspection-choice {
+ border-color: var(--ins-fail-edge);
+}
+
+.inspection-flyout .inspection-choice__option + .inspection-choice__option {
+ border-left-color: var(--ins-fail-edge);
+}
+
+.inspection-flyout .inspection-choice__option:hover:not(:disabled) {
+ background-color: var(--ins-fail-fill-strong);
+}
+
+/*
+ * The comment box belongs to the failure. ember-ui styles every console
+ * input with `body[data-theme='dark'] .fleetbase-console .form-input`, which
+ * outranks a plain `.inspection-flyout .form-input` — so an earlier attempt
+ * at this left a grey box inside a red panel. This selector is deliberately
+ * heavier than that rule, and takes its colours from the theme tokens.
+ */
+html body .inspection-flyout textarea.inspection-defect-comment.form-input {
+ background-color: var(--ins-fail-field);
+ border-color: var(--ins-fail-edge);
+ color: var(--ins-text);
+ box-shadow: none;
+}
+
+html body .inspection-flyout textarea.inspection-defect-comment.form-input::placeholder {
+ color: var(--ins-fail-placeholder);
+}
+
+html body .inspection-flyout textarea.inspection-defect-comment.form-input:focus {
+ border-color: var(--ins-fail);
+ box-shadow: 0 0 0 3px var(--ins-fail-ring);
+ outline: none;
+}
+
+/* Photos sit in the failure too: red hatching, a red-edged slot for the next. */
+.inspection-flyout .inspection-slot:not(.inspection-slot--add) {
+ border-color: var(--ins-fail-edge);
+ background: repeating-linear-gradient(45deg, var(--ins-fail-hatch-a) 0 6px, var(--ins-fail-hatch-b) 6px 12px);
+}
+
+.inspection-flyout .inspection-slot--add {
+ border-color: var(--ins-fail-edge);
+ color: var(--ins-fail-text);
+}
+
+.inspection-flyout .inspection-slot--add:hover {
+ border-color: var(--ins-fail);
+ color: var(--ins-fail);
+}
+
+/* --- on a phone, the same content as a bottom sheet --------------------- */
+
+.inspection-flyout--sheet {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1100;
+ max-height: 85vh;
+ border-bottom: 0;
+ border-radius: 0.875rem 0.875rem 0 0;
+ padding-bottom: env(safe-area-inset-bottom);
+ animation: inspection-sheet-in 0.2s ease-out;
+}
+
+/* A grab handle, so it reads as something that came up and will go down. */
+.inspection-flyout--sheet::before {
+ content: '';
+ display: block;
+ width: 2.5rem;
+ height: 0.25rem;
+ margin: 0.5rem auto 0;
+ border-radius: 9999px;
+ background-color: var(--ins-border-strong);
+}
+
+.inspection-flyout-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 1099;
+ background-color: rgb(0 0 0 / 40%);
+ animation: inspection-fade-in 0.2s ease-out;
+}
+
+@keyframes inspection-flyout-in {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes inspection-flyout-in-above {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes inspection-sheet-in {
+ from {
+ transform: translateY(100%);
+ }
+
+ to {
+ transform: none;
+ }
+}
+
+@keyframes inspection-fade-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .inspection-flyout,
+ .inspection-flyout-backdrop {
+ animation: none;
+ }
+}
+
+/* --- the defects tray -------------------------------------------------- */
+
+.inspection-tray {
+ border: 1px solid var(--ins-border);
+ border-radius: 0.375rem;
+ background-color: var(--ins-bg);
+ overflow: hidden;
+}
+
+.inspection-tray__head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--ins-border);
+ font-size: 0.6875rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ins-text-soft);
+}
+
+.inspection-tray__count {
+ min-width: 1.25rem;
+ padding: 0.125rem 0.375rem;
+ border-radius: 9999px;
+ background-color: var(--ins-fail);
+ color: #fff;
+ font-size: 0.625rem;
+ text-align: center;
+ letter-spacing: 0;
+}
+
+.inspection-tray__row {
+ appearance: none;
+ border: 0;
+ background: none;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.625rem 0.75rem;
+ text-align: left;
+ cursor: pointer;
+ color: var(--ins-text);
+ font-size: 0.8125rem;
+}
+
+.inspection-tray__row + .inspection-tray__row {
+ border-top: 1px solid var(--ins-border);
+}
+
+.inspection-tray__row:hover {
+ background-color: rgb(127 127 127 / 8%);
+}
+
+.inspection-tray__row:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: -2px;
+}
+
+.inspection-tray__severity {
+ flex-shrink: 0;
+ min-width: 4.25rem;
+ padding: 0.25rem 0.375rem;
+ border: 1px solid var(--ins-fail-edge);
+ border-radius: 0.25rem;
+ font-size: 0.625rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-align: center;
+ text-transform: uppercase;
+ color: var(--ins-fail-text);
+}
+
+.inspection-tray__severity[data-severity='high'],
+.inspection-tray__severity[data-severity='critical'] {
+ border-color: var(--ins-fail);
+ background-color: var(--ins-fail);
+ color: #fff;
+}
+
+.inspection-tray__label {
+ flex: 1 1 auto;
+ min-width: 0;
+ font-weight: 600;
+}
+
+.inspection-tray__evidence {
+ flex-shrink: 0;
+ font-size: 0.75rem;
+ color: var(--ins-text-muted);
+}
+
+.inspection-tray__evidence[data-incomplete='true'] {
+ font-weight: 600;
+ color: var(--ins-warn-strong);
+}
+
+.inspection-tray__action {
+ flex-shrink: 0;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--ins-fail-text);
+}
+
+@container (width <= 480px) {
+ .inspection-tray__row {
+ flex-wrap: wrap;
+ }
+
+ .inspection-tray__evidence {
+ flex-basis: 100%;
+ order: 5;
+ }
+}
diff --git a/addon/templates/maintenance/inspection-forms.hbs b/addon/templates/maintenance/inspection-forms.hbs
new file mode 100644
index 000000000..c24cd6895
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms.hbs
@@ -0,0 +1 @@
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-forms/index.hbs b/addon/templates/maintenance/inspection-forms/index.hbs
new file mode 100644
index 000000000..72171f8b5
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index.hbs
@@ -0,0 +1,28 @@
+
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-forms/index/details.hbs b/addon/templates/maintenance/inspection-forms/index/details.hbs
new file mode 100644
index 000000000..143f18fd2
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/details.hbs
@@ -0,0 +1,14 @@
+
+
+ {{outlet}}
+
+
diff --git a/addon/templates/maintenance/inspection-forms/index/details/index.hbs b/addon/templates/maintenance/inspection-forms/index/details/index.hbs
new file mode 100644
index 000000000..190892683
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/details/index.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/templates/maintenance/inspection-forms/index/edit.hbs b/addon/templates/maintenance/inspection-forms/index/edit.hbs
new file mode 100644
index 000000000..4a6c5e5b4
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/edit.hbs
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addon/templates/maintenance/inspection-forms/index/new.hbs b/addon/templates/maintenance/inspection-forms/index/new.hbs
new file mode 100644
index 000000000..bf5b379e9
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/new.hbs
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addon/templates/maintenance/inspection-submissions.hbs b/addon/templates/maintenance/inspection-submissions.hbs
new file mode 100644
index 000000000..c24cd6895
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions.hbs
@@ -0,0 +1 @@
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-submissions/index.hbs b/addon/templates/maintenance/inspection-submissions/index.hbs
new file mode 100644
index 000000000..19b3d01c7
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index.hbs
@@ -0,0 +1,28 @@
+
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-submissions/index/details.hbs b/addon/templates/maintenance/inspection-submissions/index/details.hbs
new file mode 100644
index 000000000..a787113ee
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/details.hbs
@@ -0,0 +1,14 @@
+
+
+ {{outlet}}
+
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/details/audit.hbs b/addon/templates/maintenance/inspection-submissions/index/details/audit.hbs
new file mode 100644
index 000000000..56b77f233
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/details/audit.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/details/index.hbs b/addon/templates/maintenance/inspection-submissions/index/details/index.hbs
new file mode 100644
index 000000000..16e7c0516
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/details/index.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/details/photos.hbs b/addon/templates/maintenance/inspection-submissions/index/details/photos.hbs
new file mode 100644
index 000000000..dbd948e04
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/details/photos.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/edit.hbs b/addon/templates/maintenance/inspection-submissions/index/edit.hbs
new file mode 100644
index 000000000..03d23e740
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/edit.hbs
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/new.hbs b/addon/templates/maintenance/inspection-submissions/index/new.hbs
new file mode 100644
index 000000000..460ad5e82
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/new.hbs
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addon/utils/fleet-ops-options.js b/addon/utils/fleet-ops-options.js
index ab201b51e..338cdd714 100644
--- a/addon/utils/fleet-ops-options.js
+++ b/addon/utils/fleet-ops-options.js
@@ -216,6 +216,41 @@ export const fuelReportStatuses = [
{ label: 'Reimbursed', value: 'reimbursed', description: 'Driver expense reimbursed' },
];
+export const inspectionFormTypes = [
+ { label: 'DVIR', value: 'dvir', description: 'Driver vehicle inspection report — the daily walk-round a driver signs.' },
+ { label: 'Pre-Trip', value: 'pre_trip', description: 'Completed before the vehicle leaves.' },
+ { label: 'Post-Trip', value: 'post_trip', description: 'Completed when the vehicle returns.' },
+ { label: 'Pre-Operational', value: 'pre_operational', description: 'Checks performed before operation begins.' },
+ { label: 'Post-Operational', value: 'post_operational', description: 'Checks performed after operation ends.' },
+ { label: 'Safety Inspection', value: 'safety_inspection', description: 'Comprehensive safety and compliance inspection.' },
+ { label: 'Maintenance Inspection', value: 'maintenance_inspection', description: 'Scheduled maintenance check and service inspection.' },
+ { label: 'Damage Assessment', value: 'damage_assessment', description: 'Inspection to assess damage or condition issues.' },
+ { label: 'Annual Inspection', value: 'annual_inspection', description: 'Yearly comprehensive vehicle inspection.' },
+ { label: 'Safety', value: 'safety', description: 'General safety checklist.' },
+ { label: 'Compliance', value: 'compliance', description: 'Regulatory or audit checklist.' },
+ { label: 'Maintenance', value: 'maintenance', description: 'Workshop or technician checklist.' },
+];
+
+export const inspectionFormStatuses = [
+ { label: 'Draft', value: 'draft', description: 'Still being written. Drivers cannot see it.' },
+ { label: 'Published', value: 'published', description: 'Available to drivers and to the public link.' },
+ { label: 'Archived', value: 'archived', description: 'Retired. Kept for the records already filed against it.' },
+];
+
+export const inspectionSeverities = [
+ { label: 'Minor', value: 'low', description: 'Monitor it. Nothing stops.' },
+ { label: 'Medium', value: 'medium', description: 'Book it in.' },
+ { label: 'High', value: 'high', description: 'Unsafe. Needs attention before the next trip.' },
+ { label: 'Critical', value: 'critical', description: 'Immobilise the vehicle.' },
+];
+
+export const inspectionSubmissionStatuses = [
+ { label: 'Draft', value: 'draft', description: 'Started but not filed.' },
+ { label: 'Submitted', value: 'submitted', description: 'Filed by the driver or the console.' },
+ { label: 'Needs Review', value: 'needs_review', description: 'Flagged for a supervisor to look at.' },
+ { label: 'Resolved', value: 'resolved', description: 'Follow-up is complete.' },
+];
+
export const workOrderStatuses = [
{ label: 'Open', value: 'open', description: 'Work order has been created and is awaiting planning or assignment' },
{ label: 'Scheduled', value: 'scheduled', description: 'Work has been planned for a specific service window' },
@@ -945,6 +980,11 @@ export default function fleetOpsOptions(key) {
routingConstraintOptions,
serviceTimePresets,
importColumnMappings,
+ // Inspections
+ inspectionFormTypes,
+ inspectionFormStatuses,
+ inspectionSeverities,
+ inspectionSubmissionStatuses,
};
return allOptions[key] ?? [];
diff --git a/addon/utils/inspection-answers.js b/addon/utils/inspection-answers.js
new file mode 100644
index 000000000..2255a5f3f
--- /dev/null
+++ b/addon/utils/inspection-answers.js
@@ -0,0 +1,283 @@
+/**
+ * Reading an inspection's answers.
+ *
+ * The sheet, its section headers and its running total all need the same
+ * questions answered — did this row pass, is a required row still blank, is
+ * anything on this form unsafe to operate — and they must agree, so they ask
+ * here rather than each working it out from the raw value.
+ *
+ * The rules mirror the driver app's `src/v3/data/useInspections.ts`, so a form
+ * filled in from a phone and the same form filled in from the console are
+ * counted the same way.
+ */
+
+import { flattenFields } from './inspection-form-structure';
+import { valueTypeForFieldType } from './inspection-field-types';
+
+/** A pass-fail answer, in one shape whatever was stored. */
+export function passFailAnswer(value) {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ return value;
+ }
+
+ // The first cut stored a bare boolean.
+ if (typeof value === 'boolean') {
+ return { passed: value, not_applicable: false };
+ }
+
+ return null;
+}
+
+/**
+ * What a pass-fail row currently says: `pass`, `fail`, `na`, or null when the
+ * field is not a pass-fail field at all.
+ */
+export function answerState(field, value) {
+ if (field?.type !== 'pass-fail') {
+ return null;
+ }
+
+ const answer = passFailAnswer(value);
+ if (!answer) {
+ return null;
+ }
+
+ if (answer.not_applicable === true) {
+ return 'na';
+ }
+
+ return answer.passed === false ? 'fail' : 'pass';
+}
+
+/** Whether a failed row was marked unsafe to operate. */
+export function isUnsafeAnswer(field, value) {
+ return answerState(field, value) === 'fail' && passFailAnswer(value)?.unsafe === true;
+}
+
+/**
+ * Whether a required field is still waiting for an answer.
+ *
+ * A pass-fail row is never blank — it opens on Pass, which is what the driver
+ * app does too — and a toggle is never blank, because off is an answer.
+ */
+export function isBlank(field, value) {
+ if (field?.type === 'pass-fail' || field?.type === 'boolean') {
+ return false;
+ }
+
+ if (value === null || value === undefined || value === '') {
+ return true;
+ }
+
+ return Array.isArray(value) && value.length === 0;
+}
+
+/** Whether a failed row is still missing the comment or photo its field demands. */
+export function defectIncomplete(field, value) {
+ if (answerState(field, value) !== 'fail') {
+ return false;
+ }
+
+ const meta = field?.meta && typeof field.meta === 'object' ? field.meta : {};
+ const answer = passFailAnswer(value) ?? {};
+
+ if (meta.require_comment_on_fail === true && !String(answer.comments ?? '').trim()) {
+ return true;
+ }
+
+ return meta.require_photo_on_fail === true && !(Array.isArray(answer.photos) && answer.photos.length > 0);
+}
+
+/** The types that always need the full width of a group, whatever the answer. */
+export const ROOMY_FIELD_TYPES = ['textarea', 'file-upload', 'signature'];
+
+/**
+ * Whether a field spans the full width of its group's grid.
+ *
+ * Only the types whose size the form itself decides: a note, an upload, a
+ * signature. It depends on the field and never on the answer, so answering a
+ * field can never change the layout. A failure's detail used to widen its
+ * field too, which re-flowed the group every time a check failed; it now
+ * opens in a flyout instead, and the field stays the size it was.
+ */
+// eslint-disable-next-line no-unused-vars
+export function isPromoted(field, value) {
+ return ROOMY_FIELD_TYPES.includes(field?.type);
+}
+
+/**
+ * What a failed check has recorded, in one shape: for the chip a closed
+ * failure leaves behind in its field, and for the defects tray.
+ */
+export function defectSummary(field, value) {
+ const answer = passFailAnswer(value) ?? {};
+ const photos = Array.isArray(answer.photos) ? answer.photos : [];
+ const meta = field?.meta && typeof field.meta === 'object' ? field.meta : {};
+ const hasComment = Boolean(String(answer.comments ?? '').trim());
+
+ return {
+ field,
+ severity: answer.severity ?? meta.severity ?? null,
+ unsafe: answer.unsafe === true,
+ photoCount: photos.length,
+ hasComment,
+ needsComment: meta.require_comment_on_fail === true && !hasComment,
+ needsPhoto: meta.require_photo_on_fail === true && photos.length === 0,
+ incomplete: defectIncomplete(field, value),
+ };
+}
+
+/** Every failed check on the sheet, in the order they are answered. */
+export function listDefects(fields = [], values = {}) {
+ return fields.filter((field) => answerState(field, values?.[field.uuid]) === 'fail').map((field) => defectSummary(field, values?.[field.uuid]));
+}
+
+/**
+ * The one-word state a group header's dot shows for a field: `pass`, `fail`,
+ * `na`, `outstanding` (required and unanswered, or a failure still owing its
+ * comment or photo), `done`, or `empty`.
+ */
+export function fieldMarker(field, value) {
+ const state = answerState(field, value);
+
+ if (state === 'fail') {
+ return defectIncomplete(field, value) ? 'outstanding' : 'fail';
+ }
+
+ if (state) {
+ return state;
+ }
+
+ if (field?.required && isBlank(field, value)) {
+ return 'outstanding';
+ }
+
+ return isBlank(field, value) ? 'empty' : 'done';
+}
+
+/**
+ * The totals a section header and the sheet's foot both read.
+ *
+ * `outstanding` is what still stops the sheet being finished: a required field
+ * left blank, or a failure that owes a comment or a photo.
+ */
+export function summarize(fields = [], values = {}) {
+ const summary = {
+ total: fields.length,
+ checks: 0,
+ passed: 0,
+ failed: 0,
+ notApplicable: 0,
+ missingRequired: 0,
+ incompleteDefects: 0,
+ unsafe: false,
+ unsafeField: null,
+ firstOutstanding: null,
+ };
+
+ for (const field of fields) {
+ const value = values?.[field.uuid];
+ const state = answerState(field, value);
+
+ if (state) {
+ summary.checks += 1;
+
+ if (state === 'pass') {
+ summary.passed += 1;
+ } else if (state === 'fail') {
+ summary.failed += 1;
+ } else {
+ summary.notApplicable += 1;
+ }
+ }
+
+ if (field.required && isBlank(field, value)) {
+ summary.missingRequired += 1;
+ }
+
+ if (defectIncomplete(field, value)) {
+ summary.incompleteDefects += 1;
+ }
+
+ // The banners name the field rather than counting it, so an inspector
+ // is told what to go and fix, not how many things are wrong.
+ if (!summary.firstOutstanding && fieldMarker(field, value) === 'outstanding') {
+ summary.firstOutstanding = field;
+ }
+
+ if (isUnsafeAnswer(field, value)) {
+ summary.unsafe = true;
+
+ if (!summary.unsafeField) {
+ summary.unsafeField = field;
+ }
+ }
+ }
+
+ summary.outstanding = summary.missingRequired + summary.incompleteDefects;
+
+ return summary;
+}
+
+/**
+ * The answer every field starts with.
+ *
+ * A pass-fail row opens on Pass, so a sheet saved untouched still files a
+ * complete set — which is what the driver app does, and what the first cut of
+ * the console did. Everything else starts empty.
+ */
+export function seedAnswers(groups = [], stored = {}) {
+ return flattenFields(groups).reduce((carry, field) => {
+ if (stored?.[field.uuid] !== undefined) {
+ carry[field.uuid] = stored[field.uuid];
+ return carry;
+ }
+
+ carry[field.uuid] = field.type === 'pass-fail' ? { passed: true, not_applicable: false, severity: null, comments: '', photos: [], unsafe: false } : null;
+
+ return carry;
+ }, {});
+}
+
+/**
+ * A file value is a reference on the way out, whichever way it came in: the
+ * `file:` a fresh upload leaves, or the `{ id, url, … }` the submission
+ * resource resolved a stored reference to.
+ */
+function serializeFile(value) {
+ if (value && typeof value === 'object') {
+ return value.id ?? null;
+ }
+
+ return typeof value === 'string' && value !== '' ? value : null;
+}
+
+function serializeValue(field, value) {
+ if (field.type === 'pass-fail') {
+ const answer = passFailAnswer(value) ?? { passed: true, not_applicable: false };
+
+ return {
+ ...answer,
+ photos: (Array.isArray(answer.photos) ? answer.photos : []).map(serializeFile).filter(Boolean),
+ };
+ }
+
+ if (field.type === 'file-upload' || field.type === 'signature') {
+ return serializeFile(value);
+ }
+
+ return value;
+}
+
+/**
+ * The answers as the server takes them — the same `custom_field_values` body
+ * the driver API accepts, so the console, a public link and the app all file
+ * the same rows and the item results are derived from the same place.
+ */
+export function answerRows(fields = [], values = {}) {
+ return fields.map((field) => ({
+ custom_field: field.uuid,
+ value_type: valueTypeForFieldType(field.type),
+ value: serializeValue(field, values?.[field.uuid]),
+ }));
+}
diff --git a/addon/utils/inspection-field-types.js b/addon/utils/inspection-field-types.js
new file mode 100644
index 000000000..97b56f425
--- /dev/null
+++ b/addon/utils/inspection-field-types.js
@@ -0,0 +1,74 @@
+/**
+ * The field types an inspection form may be built from.
+ *
+ * This list mirrors `Fleetbase\FleetOps\Models\InspectionForm::FIELD_TYPES`
+ * exactly — the server refuses anything else and falls back to `input`, so the
+ * builder must not offer a type the writer will silently rewrite.
+ */
+export const INSPECTION_FIELD_TYPES = ['pass-fail', 'input', 'textarea', 'number', 'select', 'radio-button', 'boolean', 'date-picker', 'date-time-input', 'file-upload', 'signature'];
+
+/** The severities a failed pass-fail answer can carry. */
+export const INSPECTION_SEVERITIES = ['low', 'medium', 'high', 'critical'];
+
+/** The types whose answer is chosen from a list the author writes. */
+export const OPTION_FIELD_TYPES = ['select', 'radio-button'];
+
+/**
+ * The types FleetOps renders itself. `pass-fail`, `signature` and the
+ * inspection flavour of `file-upload` are inspection-only; `textarea`,
+ * `number` and `boolean` are ordinary but absent from the platform's
+ * custom-field type map, so there is nothing to delegate them to.
+ */
+export const FLEETOPS_OWNED_FIELD_TYPES = ['pass-fail', 'signature', 'file-upload', 'textarea', 'number', 'boolean'];
+
+/** The types the platform's own `custom-field/input` already renders. */
+export const DELEGATED_FIELD_TYPES = ['input', 'select', 'radio-button', 'date-picker', 'date-time-input'];
+
+/**
+ * The console component that renders a field type. Mirrors
+ * `InspectionFormSync::componentFor()` so a field built here and a field
+ * converted from the first cut's checklist name the same component.
+ */
+export function componentForFieldType(type) {
+ return type === 'radio-button' ? 'radio-button-select' : type;
+}
+
+/**
+ * How the server stores an answer of this type — the `value_type` a submitted
+ * `custom_field_values` row carries. Mirrors `InspectionSubmitter::normalizeValue()`
+ * and the app's own `valueTypeFor` in `src/v3/data/useInspections.ts`, so the
+ * console and the driver app file the same rows.
+ */
+export function valueTypeForFieldType(type) {
+ switch (type) {
+ case 'pass-fail':
+ return 'object';
+ case 'file-upload':
+ case 'signature':
+ return 'file';
+ case 'number':
+ return 'number';
+ case 'boolean':
+ return 'boolean';
+ case 'date-picker':
+ return 'date';
+ case 'date-time-input':
+ return 'datetime';
+ default:
+ return 'text';
+ }
+}
+
+/** Whether a field of this type needs the author to write its options. */
+export function isOptionFieldType(type) {
+ return OPTION_FIELD_TYPES.includes(type);
+}
+
+/** Whether FleetOps renders this type itself rather than delegating it. */
+export function isFleetOpsOwnedFieldType(type) {
+ return FLEETOPS_OWNED_FIELD_TYPES.includes(type);
+}
+
+export default function inspectionFieldTypes() {
+ return INSPECTION_FIELD_TYPES;
+}
diff --git a/addon/utils/inspection-form-structure.js b/addon/utils/inspection-form-structure.js
new file mode 100644
index 000000000..6f655fe6d
--- /dev/null
+++ b/addon/utils/inspection-form-structure.js
@@ -0,0 +1,161 @@
+import generateUuid from '@fleetbase/ember-core/utils/generate-uuid';
+import isObject from '@fleetbase/ember-core/utils/is-object';
+import { componentForFieldType, INSPECTION_FIELD_TYPES } from './inspection-field-types';
+
+/**
+ * A form's structure, in the one shape the console holds it in.
+ *
+ * The `inspection-form` model belongs to `@fleetbase/fleetops-data` and
+ * declares no structure attribute, so the builder cannot hang the groups off
+ * the record and let Ember Data carry them. It reads the structure from the
+ * internal form payload and writes it back whole under
+ * `inspection_form.field_groups`, which is what
+ * `InspectionFormController::syncStructureFromRequest()` looks for and what
+ * `InspectionFormSync::sync()` matches on `uuid`.
+ *
+ * Everything here is plain objects. Nothing in this file mutates its argument.
+ */
+
+/** Sorts groups or fields the way the builder laid them out. */
+function byOrder(a, b) {
+ const ao = a?.order ?? Number.MAX_SAFE_INTEGER;
+ const bo = b?.order ?? Number.MAX_SAFE_INTEGER;
+ return ao - bo;
+}
+
+function metaOf(value) {
+ return isObject(value) ? { ...value } : {};
+}
+
+/** One field, as the builder and the answering screen both read it. */
+export function normalizeField(field, index = 0) {
+ const type = INSPECTION_FIELD_TYPES.includes(field?.type) ? field.type : 'input';
+
+ return {
+ uuid: field?.uuid ?? field?.id ?? generateUuid(),
+ name: field?.name ?? '',
+ label: field?.label ?? '',
+ description: field?.description ?? null,
+ help_text: field?.help_text ?? null,
+ type,
+ component: field?.component ?? componentForFieldType(type),
+ required: Boolean(field?.required),
+ editable: field?.editable === undefined ? true : Boolean(field.editable),
+ options: Array.isArray(field?.options) ? [...field.options] : [],
+ order: field?.order ?? index + 1,
+ meta: metaOf(field?.meta),
+ };
+}
+
+/** One group, with its fields inside it and sorted. */
+export function normalizeGroup(group, index = 0, fields = []) {
+ const own = Array.isArray(group?.fields) ? group.fields : Array.isArray(group?.customFields) ? group.customFields : fields;
+
+ return {
+ uuid: group?.uuid ?? group?.id ?? generateUuid(),
+ name: group?.name ?? '',
+ description: group?.description ?? null,
+ order: group?.order ?? index + 1,
+ meta: { grid_size: 1, ...metaOf(group?.meta) },
+ fields: [...own].sort(byOrder).map((field, fieldIndex) => normalizeField(field, fieldIndex)),
+ };
+}
+
+/**
+ * The structure held in an internal form payload.
+ *
+ * A read carries `field_groups` (the groups alone) beside a flat `fields` list
+ * that names its group by `category_uuid`; `grouped_fields` carries the same
+ * thing already nested, and is what the driver API answers with. Either is
+ * accepted, so this works against a record read through the console and one
+ * read through the public link.
+ */
+export function normalizeFieldGroups(payload) {
+ if (!payload) {
+ return [];
+ }
+
+ const groups = Array.isArray(payload.field_groups) ? payload.field_groups : [];
+ const fields = Array.isArray(payload.fields) ? payload.fields : [];
+
+ const fromFieldGroups = groups
+ .slice()
+ .sort(byOrder)
+ .map((group, index) => {
+ const groupUuid = group?.uuid ?? group?.id;
+ const own = fields.filter((field) => (field?.category_uuid ?? null) === groupUuid);
+ return normalizeGroup(group, index, own);
+ });
+
+ const fromGroupedFields = (Array.isArray(payload.grouped_fields) ? payload.grouped_fields : [])
+ .slice()
+ .sort(byOrder)
+ .map((group, index) => normalizeGroup(group, index));
+
+ /*
+ * Two shapes describe the same structure. `field_groups` carries no fields
+ * of its own — they arrive in the sibling `fields` array, joined on
+ * `category_uuid` — while `grouped_fields` nests them. A public payload
+ * omits `category_uuid`, so the join finds nothing and every group comes
+ * back empty; take whichever shape actually produced fields.
+ *
+ * When neither did, prefer `field_groups`: a form whose groups are laid
+ * out but still empty is a real state in the builder, and returning
+ * nothing would lose those groups.
+ */
+ if (fromFieldGroups.some((group) => group.fields.length > 0)) {
+ return fromFieldGroups;
+ }
+
+ if (fromGroupedFields.some((group) => group.fields.length > 0)) {
+ return fromGroupedFields;
+ }
+
+ return fromFieldGroups.length ? fromFieldGroups : fromGroupedFields;
+}
+
+/** Every field of every group, flattened, in the order they are answered. */
+export function flattenFields(groups = []) {
+ return groups.reduce((carry, group) => carry.concat(Array.isArray(group?.fields) ? group.fields : []), []);
+}
+
+/**
+ * The structure as the server writes it. `order` is rewritten from the
+ * builder's own ordering so a drag or a delete renumbers the whole form, and
+ * the uuid is kept so a second save updates rather than duplicates.
+ */
+export function serializeFieldGroups(groups = []) {
+ return groups.map((group, groupIndex) => ({
+ uuid: group.uuid,
+ name: group.name,
+ description: group.description ?? null,
+ order: groupIndex + 1,
+ meta: metaOf(group.meta),
+ fields: (Array.isArray(group.fields) ? group.fields : []).map((field, fieldIndex) => ({
+ uuid: field.uuid,
+ name: field.name || null,
+ label: field.label,
+ description: field.description ?? null,
+ help_text: field.help_text ?? null,
+ type: field.type,
+ component: componentForFieldType(field.type),
+ required: Boolean(field.required),
+ editable: field.editable === undefined ? true : Boolean(field.editable),
+ options: Array.isArray(field.options) ? field.options.filter((option) => typeof option === 'string' && option.trim() !== '') : [],
+ order: fieldIndex + 1,
+ meta: metaOf(field.meta),
+ })),
+ }));
+}
+
+/** A blank group, ready for the builder to name. */
+export function createFieldGroup(attributes = {}) {
+ return normalizeGroup({ uuid: generateUuid(), name: '', meta: { grid_size: 1 }, fields: [], ...attributes });
+}
+
+/** A blank field of the given type. */
+export function createField(type = 'pass-fail', attributes = {}) {
+ const meta = type === 'pass-fail' ? { severity: 'medium', require_photo_on_fail: false, require_comment_on_fail: false, unsafe_on_fail: false } : {};
+
+ return normalizeField({ uuid: generateUuid(), label: '', type, meta, ...attributes });
+}
diff --git a/app/components/inspection-field/form.js b/app/components/inspection-field/form.js
new file mode 100644
index 000000000..b7aa86835
--- /dev/null
+++ b/app/components/inspection-field/form.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-field/form';
diff --git a/app/components/inspection-field/input.js b/app/components/inspection-field/input.js
new file mode 100644
index 000000000..c99fd657a
--- /dev/null
+++ b/app/components/inspection-field/input.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-field/input';
diff --git a/app/components/inspection-field/value.js b/app/components/inspection-field/value.js
new file mode 100644
index 000000000..ad0a25c1c
--- /dev/null
+++ b/app/components/inspection-field/value.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-field/value';
diff --git a/app/components/inspection-flyout.js b/app/components/inspection-flyout.js
new file mode 100644
index 000000000..ac0b52ba2
--- /dev/null
+++ b/app/components/inspection-flyout.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-flyout';
diff --git a/app/components/inspection-form/builder.js b/app/components/inspection-form/builder.js
new file mode 100644
index 000000000..55ff7f1e9
--- /dev/null
+++ b/app/components/inspection-form/builder.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-form/builder';
diff --git a/app/components/inspection-form/details.js b/app/components/inspection-form/details.js
new file mode 100644
index 000000000..fdb229602
--- /dev/null
+++ b/app/components/inspection-form/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-form/details';
diff --git a/app/components/inspection-form/form.js b/app/components/inspection-form/form.js
new file mode 100644
index 000000000..2d0c16046
--- /dev/null
+++ b/app/components/inspection-form/form.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-form/form';
diff --git a/app/components/inspection-link/list.js b/app/components/inspection-link/list.js
new file mode 100644
index 000000000..dc3d6244f
--- /dev/null
+++ b/app/components/inspection-link/list.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-link/list';
diff --git a/app/components/inspection-sheet.js b/app/components/inspection-sheet.js
new file mode 100644
index 000000000..57053b1cd
--- /dev/null
+++ b/app/components/inspection-sheet.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-sheet';
diff --git a/app/components/inspection-sheet/group.js b/app/components/inspection-sheet/group.js
new file mode 100644
index 000000000..f8190b170
--- /dev/null
+++ b/app/components/inspection-sheet/group.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-sheet/group';
diff --git a/app/components/inspection-submission/details.js b/app/components/inspection-submission/details.js
new file mode 100644
index 000000000..471c6437a
--- /dev/null
+++ b/app/components/inspection-submission/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-submission/details';
diff --git a/app/components/inspection-submission/form.js b/app/components/inspection-submission/form.js
new file mode 100644
index 000000000..d8ea4bc64
--- /dev/null
+++ b/app/components/inspection-submission/form.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-submission/form';
diff --git a/app/components/inspection-submission/photos.js b/app/components/inspection-submission/photos.js
new file mode 100644
index 000000000..172bb17c1
--- /dev/null
+++ b/app/components/inspection-submission/photos.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-submission/photos';
diff --git a/app/components/modals/inspection-link.js b/app/components/modals/inspection-link.js
new file mode 100644
index 000000000..520107efc
--- /dev/null
+++ b/app/components/modals/inspection-link.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/modals/inspection-link';
diff --git a/app/components/public-inspection.js b/app/components/public-inspection.js
new file mode 100644
index 000000000..964f60f2c
--- /dev/null
+++ b/app/components/public-inspection.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/public-inspection';
diff --git a/app/components/select-option.js b/app/components/select-option.js
new file mode 100644
index 000000000..350e72565
--- /dev/null
+++ b/app/components/select-option.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/select-option';
diff --git a/app/components/select-option/driver.js b/app/components/select-option/driver.js
new file mode 100644
index 000000000..85f6b655b
--- /dev/null
+++ b/app/components/select-option/driver.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/select-option/driver';
diff --git a/app/components/select-option/user.js b/app/components/select-option/user.js
new file mode 100644
index 000000000..0fe9ca48a
--- /dev/null
+++ b/app/components/select-option/user.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/select-option/user';
diff --git a/app/components/select-option/vehicle.js b/app/components/select-option/vehicle.js
new file mode 100644
index 000000000..faa85864d
--- /dev/null
+++ b/app/components/select-option/vehicle.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/select-option/vehicle';
diff --git a/app/components/table/cell/fleet-ops-option.js b/app/components/table/cell/fleet-ops-option.js
new file mode 100644
index 000000000..2c6995c32
--- /dev/null
+++ b/app/components/table/cell/fleet-ops-option.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/table/cell/fleet-ops-option';
diff --git a/app/controllers/maintenance/inspection-forms/index.js b/app/controllers/maintenance/inspection-forms/index.js
new file mode 100644
index 000000000..658466d90
--- /dev/null
+++ b/app/controllers/maintenance/inspection-forms/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-forms/index';
diff --git a/app/controllers/maintenance/inspection-forms/index/details.js b/app/controllers/maintenance/inspection-forms/index/details.js
new file mode 100644
index 000000000..a1589e7d1
--- /dev/null
+++ b/app/controllers/maintenance/inspection-forms/index/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-forms/index/details';
diff --git a/app/controllers/maintenance/inspection-forms/index/edit.js b/app/controllers/maintenance/inspection-forms/index/edit.js
new file mode 100644
index 000000000..8e7ec60af
--- /dev/null
+++ b/app/controllers/maintenance/inspection-forms/index/edit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-forms/index/edit';
diff --git a/app/controllers/maintenance/inspection-forms/index/new.js b/app/controllers/maintenance/inspection-forms/index/new.js
new file mode 100644
index 000000000..e0d1dfaac
--- /dev/null
+++ b/app/controllers/maintenance/inspection-forms/index/new.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-forms/index/new';
diff --git a/app/controllers/maintenance/inspection-submissions/index.js b/app/controllers/maintenance/inspection-submissions/index.js
new file mode 100644
index 000000000..3778a2e95
--- /dev/null
+++ b/app/controllers/maintenance/inspection-submissions/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-submissions/index';
diff --git a/app/controllers/maintenance/inspection-submissions/index/details.js b/app/controllers/maintenance/inspection-submissions/index/details.js
new file mode 100644
index 000000000..604b86920
--- /dev/null
+++ b/app/controllers/maintenance/inspection-submissions/index/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-submissions/index/details';
diff --git a/app/controllers/maintenance/inspection-submissions/index/edit.js b/app/controllers/maintenance/inspection-submissions/index/edit.js
new file mode 100644
index 000000000..df557f39e
--- /dev/null
+++ b/app/controllers/maintenance/inspection-submissions/index/edit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-submissions/index/edit';
diff --git a/app/controllers/maintenance/inspection-submissions/index/new.js b/app/controllers/maintenance/inspection-submissions/index/new.js
new file mode 100644
index 000000000..c5d5b20ed
--- /dev/null
+++ b/app/controllers/maintenance/inspection-submissions/index/new.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-submissions/index/new';
diff --git a/app/modifiers/inspection-flyout.js b/app/modifiers/inspection-flyout.js
new file mode 100644
index 000000000..6d152b644
--- /dev/null
+++ b/app/modifiers/inspection-flyout.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/modifiers/inspection-flyout';
diff --git a/app/modifiers/sync-value.js b/app/modifiers/sync-value.js
new file mode 100644
index 000000000..99eab02a0
--- /dev/null
+++ b/app/modifiers/sync-value.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/modifiers/sync-value';
diff --git a/app/modifiers/when-changed.js b/app/modifiers/when-changed.js
new file mode 100644
index 000000000..857f44d5b
--- /dev/null
+++ b/app/modifiers/when-changed.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/modifiers/when-changed';
diff --git a/app/routes/maintenance/inspection-forms.js b/app/routes/maintenance/inspection-forms.js
new file mode 100644
index 000000000..6e1880140
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms';
diff --git a/app/routes/maintenance/inspection-forms/index.js b/app/routes/maintenance/inspection-forms/index.js
new file mode 100644
index 000000000..78e1f0b28
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms/index';
diff --git a/app/routes/maintenance/inspection-forms/index/details.js b/app/routes/maintenance/inspection-forms/index/details.js
new file mode 100644
index 000000000..46c57d525
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms/index/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms/index/details';
diff --git a/app/routes/maintenance/inspection-forms/index/details/index.js b/app/routes/maintenance/inspection-forms/index/details/index.js
new file mode 100644
index 000000000..05712c55a
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms/index/details/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms/index/details/index';
diff --git a/app/routes/maintenance/inspection-forms/index/edit.js b/app/routes/maintenance/inspection-forms/index/edit.js
new file mode 100644
index 000000000..f6007ae62
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms/index/edit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms/index/edit';
diff --git a/app/routes/maintenance/inspection-forms/index/new.js b/app/routes/maintenance/inspection-forms/index/new.js
new file mode 100644
index 000000000..e7d7a677b
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms/index/new.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms/index/new';
diff --git a/app/routes/maintenance/inspection-submissions.js b/app/routes/maintenance/inspection-submissions.js
new file mode 100644
index 000000000..613391020
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions';
diff --git a/app/routes/maintenance/inspection-submissions/index.js b/app/routes/maintenance/inspection-submissions/index.js
new file mode 100644
index 000000000..aaeb0ff1f
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index';
diff --git a/app/routes/maintenance/inspection-submissions/index/details.js b/app/routes/maintenance/inspection-submissions/index/details.js
new file mode 100644
index 000000000..342a1d491
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/details';
diff --git a/app/routes/maintenance/inspection-submissions/index/details/audit.js b/app/routes/maintenance/inspection-submissions/index/details/audit.js
new file mode 100644
index 000000000..c7ffdd249
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index/details/audit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/details/audit';
diff --git a/app/routes/maintenance/inspection-submissions/index/details/index.js b/app/routes/maintenance/inspection-submissions/index/details/index.js
new file mode 100644
index 000000000..cf373b50f
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index/details/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/details/index';
diff --git a/app/routes/maintenance/inspection-submissions/index/details/photos.js b/app/routes/maintenance/inspection-submissions/index/details/photos.js
new file mode 100644
index 000000000..1fa654a4b
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index/details/photos.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/details/photos';
diff --git a/app/routes/maintenance/inspection-submissions/index/edit.js b/app/routes/maintenance/inspection-submissions/index/edit.js
new file mode 100644
index 000000000..15cdf427d
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index/edit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/edit';
diff --git a/app/routes/maintenance/inspection-submissions/index/new.js b/app/routes/maintenance/inspection-submissions/index/new.js
new file mode 100644
index 000000000..ffd2fc854
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index/new.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/new';
diff --git a/app/services/inspection-form-actions.js b/app/services/inspection-form-actions.js
new file mode 100644
index 000000000..c478f90c6
--- /dev/null
+++ b/app/services/inspection-form-actions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/services/inspection-form-actions';
diff --git a/app/services/inspection-submission-actions.js b/app/services/inspection-submission-actions.js
new file mode 100644
index 000000000..a98b3dab0
--- /dev/null
+++ b/app/services/inspection-submission-actions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/services/inspection-submission-actions';
diff --git a/app/templates/maintenance/inspection-forms.js b/app/templates/maintenance/inspection-forms.js
new file mode 100644
index 000000000..3d144bf67
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms';
diff --git a/app/templates/maintenance/inspection-forms/index.js b/app/templates/maintenance/inspection-forms/index.js
new file mode 100644
index 000000000..b637e78d1
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms/index';
diff --git a/app/templates/maintenance/inspection-forms/index/details.js b/app/templates/maintenance/inspection-forms/index/details.js
new file mode 100644
index 000000000..3e1b537b8
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms/index/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms/index/details';
diff --git a/app/templates/maintenance/inspection-forms/index/details/index.js b/app/templates/maintenance/inspection-forms/index/details/index.js
new file mode 100644
index 000000000..335eebd25
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms/index/details/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms/index/details/index';
diff --git a/app/templates/maintenance/inspection-forms/index/edit.js b/app/templates/maintenance/inspection-forms/index/edit.js
new file mode 100644
index 000000000..906dc3cac
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms/index/edit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms/index/edit';
diff --git a/app/templates/maintenance/inspection-forms/index/new.js b/app/templates/maintenance/inspection-forms/index/new.js
new file mode 100644
index 000000000..d76d94a29
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms/index/new.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms/index/new';
diff --git a/app/templates/maintenance/inspection-submissions.js b/app/templates/maintenance/inspection-submissions.js
new file mode 100644
index 000000000..90158422a
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions';
diff --git a/app/templates/maintenance/inspection-submissions/index.js b/app/templates/maintenance/inspection-submissions/index.js
new file mode 100644
index 000000000..d1e5594c4
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index';
diff --git a/app/templates/maintenance/inspection-submissions/index/details.js b/app/templates/maintenance/inspection-submissions/index/details.js
new file mode 100644
index 000000000..e48e1e6af
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/details';
diff --git a/app/templates/maintenance/inspection-submissions/index/details/audit.js b/app/templates/maintenance/inspection-submissions/index/details/audit.js
new file mode 100644
index 000000000..094ec5262
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index/details/audit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/details/audit';
diff --git a/app/templates/maintenance/inspection-submissions/index/details/index.js b/app/templates/maintenance/inspection-submissions/index/details/index.js
new file mode 100644
index 000000000..b342b3262
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index/details/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/details/index';
diff --git a/app/templates/maintenance/inspection-submissions/index/details/photos.js b/app/templates/maintenance/inspection-submissions/index/details/photos.js
new file mode 100644
index 000000000..d9b86fd4e
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index/details/photos.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/details/photos';
diff --git a/app/templates/maintenance/inspection-submissions/index/edit.js b/app/templates/maintenance/inspection-submissions/index/edit.js
new file mode 100644
index 000000000..f3b7b51c0
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index/edit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/edit';
diff --git a/app/templates/maintenance/inspection-submissions/index/new.js b/app/templates/maintenance/inspection-submissions/index/new.js
new file mode 100644
index 000000000..9d7bf2372
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index/new.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/new';
diff --git a/app/utils/inspection-field-types.js b/app/utils/inspection-field-types.js
new file mode 100644
index 000000000..1f8e9cea2
--- /dev/null
+++ b/app/utils/inspection-field-types.js
@@ -0,0 +1,2 @@
+export { default } from '@fleetbase/fleetops-engine/utils/inspection-field-types';
+export * from '@fleetbase/fleetops-engine/utils/inspection-field-types';
diff --git a/app/utils/inspection-form-structure.js b/app/utils/inspection-form-structure.js
new file mode 100644
index 000000000..a9db08bed
--- /dev/null
+++ b/app/utils/inspection-form-structure.js
@@ -0,0 +1 @@
+export * from '@fleetbase/fleetops-engine/utils/inspection-form-structure';
diff --git a/server/migrations/2026_09_09_000001_create_inspection_tables.php b/server/migrations/2026_09_09_000001_create_inspection_tables.php
new file mode 100644
index 000000000..765246d8b
--- /dev/null
+++ b/server/migrations/2026_09_09_000001_create_inspection_tables.php
@@ -0,0 +1,152 @@
+increments('id');
+ $table->uuid('uuid')->index();
+ $table->string('_key')->nullable()->index();
+ $table->string('public_id', 191)->nullable()->unique()->index();
+ $table->foreignUuid('company_uuid')->constrained('companies', 'uuid')->cascadeOnDelete();
+
+ $table->string('name')->index();
+ $table->text('description')->nullable();
+ $table->string('type')->default('dvir')->index();
+ $table->string('status')->default('draft')->index();
+ $table->string('frequency')->nullable()->index();
+
+ $table->string('subject_type')->nullable();
+ $table->uuid('subject_uuid')->nullable();
+ $table->index(['subject_type', 'subject_uuid']);
+
+ $table->json('items')->nullable();
+ $table->json('settings')->nullable();
+ $table->json('meta')->nullable();
+ $table->timestamp('published_at')->nullable()->index();
+
+ $table->foreignUuid('created_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+ $table->foreignUuid('updated_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+
+ $table->softDeletes();
+ $table->timestamps();
+
+ $table->index(['company_uuid', 'status', 'type']);
+ });
+
+ Schema::create('inspection_links', function (Blueprint $table) {
+ $table->increments('id');
+ $table->uuid('uuid')->index();
+ $table->string('_key')->nullable()->index();
+ $table->string('public_id', 191)->nullable()->unique()->index();
+ $table->foreignUuid('company_uuid')->constrained('companies', 'uuid')->cascadeOnDelete();
+ $table->foreignUuid('inspection_form_uuid')->constrained('inspection_forms', 'uuid')->cascadeOnDelete();
+ $table->foreignUuid('driver_uuid')->nullable()->constrained('drivers', 'uuid')->nullOnDelete();
+ $table->foreignUuid('vehicle_uuid')->nullable()->constrained('vehicles', 'uuid')->nullOnDelete();
+ $table->foreignUuid('created_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+
+ $table->string('token_hash', 191)->unique();
+ $table->string('status')->default('active')->index();
+ $table->boolean('single_use')->default(true)->index();
+ $table->timestamp('expires_at')->nullable()->index();
+ $table->timestamp('last_viewed_at')->nullable();
+ $table->timestamp('used_at')->nullable()->index();
+ $table->string('used_ip')->nullable();
+ $table->text('used_user_agent')->nullable();
+ $table->json('meta')->nullable();
+
+ $table->softDeletes();
+ $table->timestamps();
+
+ $table->index(['company_uuid', 'inspection_form_uuid', 'status']);
+ });
+
+ Schema::create('inspection_submissions', function (Blueprint $table) {
+ $table->increments('id');
+ $table->uuid('uuid')->index();
+ $table->string('_key')->nullable()->index();
+ $table->string('public_id', 191)->nullable()->unique()->index();
+ $table->foreignUuid('company_uuid')->constrained('companies', 'uuid')->cascadeOnDelete();
+ $table->foreignUuid('inspection_form_uuid')->nullable()->constrained('inspection_forms', 'uuid')->nullOnDelete();
+ $table->foreignUuid('vehicle_uuid')->nullable()->constrained('vehicles', 'uuid')->nullOnDelete();
+ $table->foreignUuid('driver_uuid')->nullable()->constrained('drivers', 'uuid')->nullOnDelete();
+ $table->foreignUuid('submitted_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+ $table->foreignUuid('issue_uuid')->nullable()->constrained('issues', 'uuid')->nullOnDelete();
+ $table->foreignUuid('work_order_uuid')->nullable()->constrained('work_orders', 'uuid')->nullOnDelete();
+
+ $table->string('type')->default('dvir')->index();
+ $table->string('status')->default('draft')->index();
+ $table->string('result')->nullable()->index();
+ $table->string('source')->nullable()->index();
+ $table->unsignedBigInteger('odometer')->nullable();
+ $table->unsignedBigInteger('engine_hours')->nullable();
+ $table->unsignedInteger('total_items')->default(0);
+ $table->unsignedInteger('failed_items')->default(0);
+ $table->timestamp('started_at')->nullable()->index();
+ $table->timestamp('submitted_at')->nullable()->index();
+ $table->timestamp('resolved_at')->nullable()->index();
+
+ $table->json('location')->nullable();
+ $table->json('signature')->nullable();
+ $table->json('attachments')->nullable();
+ $table->json('meta')->nullable();
+
+ $table->foreignUuid('created_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+ $table->foreignUuid('updated_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+
+ $table->softDeletes();
+ $table->timestamps();
+
+ $table->index(['company_uuid', 'status', 'result']);
+ $table->index(['vehicle_uuid', 'submitted_at']);
+ });
+
+ Schema::create('inspection_item_results', function (Blueprint $table) {
+ $table->increments('id');
+ $table->uuid('uuid')->index();
+ $table->string('_key')->nullable()->index();
+ $table->foreignUuid('company_uuid')->constrained('companies', 'uuid')->cascadeOnDelete();
+ $table->foreignUuid('inspection_submission_uuid')->constrained('inspection_submissions', 'uuid')->cascadeOnDelete();
+ $table->foreignUuid('issue_uuid')->nullable()->constrained('issues', 'uuid')->nullOnDelete();
+ $table->foreignUuid('work_order_uuid')->nullable()->constrained('work_orders', 'uuid')->nullOnDelete();
+
+ $table->string('item_key')->nullable()->index();
+ $table->string('label')->index();
+ $table->string('category')->nullable()->index();
+ $table->string('status')->default('passed')->index();
+ $table->string('severity')->nullable()->index();
+ $table->boolean('passed')->default(true)->index();
+ $table->text('comments')->nullable();
+ $table->json('photos')->nullable();
+ $table->json('meta')->nullable();
+
+ $table->foreignUuid('created_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+ $table->foreignUuid('updated_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+
+ $table->softDeletes();
+ $table->timestamps();
+
+ $table->index(['company_uuid', 'passed', 'severity']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::disableForeignKeyConstraints();
+ Schema::dropIfExists('inspection_item_results');
+ Schema::dropIfExists('inspection_submissions');
+ Schema::dropIfExists('inspection_links');
+ Schema::dropIfExists('inspection_forms');
+ Schema::enableForeignKeyConstraints();
+ }
+};
diff --git a/server/migrations/2026_09_10_000001_convert_inspection_form_items_to_field_groups.php b/server/migrations/2026_09_10_000001_convert_inspection_form_items_to_field_groups.php
new file mode 100644
index 000000000..03c8fbb3f
--- /dev/null
+++ b/server/migrations/2026_09_10_000001_convert_inspection_form_items_to_field_groups.php
@@ -0,0 +1,44 @@
+whereNotNull('items')
+ ->orderBy('id')
+ ->chunkById(100, function ($forms) {
+ foreach ($forms as $form) {
+ InspectionFormSync::convertLegacyItems($form);
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ // Nothing to undo: `items` was never removed, and the fields written
+ // here may have been edited since.
+ }
+};
diff --git a/server/migrations/2026_09_10_000002_add_token_to_inspection_links.php b/server/migrations/2026_09_10_000002_add_token_to_inspection_links.php
new file mode 100644
index 000000000..67a775144
--- /dev/null
+++ b/server/migrations/2026_09_10_000002_add_token_to_inspection_links.php
@@ -0,0 +1,39 @@
+text('token')->nullable()->after('token_hash');
+ });
+ }
+
+ public function down()
+ {
+ Schema::table('inspection_links', function (Blueprint $table) {
+ $table->dropColumn('token');
+ });
+ }
+};
diff --git a/server/migrations/2026_09_11_000001_add_assignee_and_pin_to_inspection_links.php b/server/migrations/2026_09_11_000001_add_assignee_and_pin_to_inspection_links.php
new file mode 100644
index 000000000..71ad88239
--- /dev/null
+++ b/server/migrations/2026_09_11_000001_add_assignee_and_pin_to_inspection_links.php
@@ -0,0 +1,39 @@
+foreignUuid('assignee_uuid')->nullable()->after('vehicle_uuid')->constrained('users', 'uuid')->nullOnDelete();
+ $table->string('pin_hash')->nullable()->after('token');
+ $table->text('pin')->nullable()->after('pin_hash');
+ $table->unsignedSmallInteger('pin_attempts')->default(0)->after('pin');
+ $table->string('pin_sent_via', 20)->nullable()->after('pin_attempts');
+ $table->timestamp('pin_sent_at')->nullable()->after('pin_sent_via');
+ });
+ }
+
+ public function down()
+ {
+ Schema::table('inspection_links', function (Blueprint $table) {
+ $table->dropConstrainedForeignId('assignee_uuid');
+ $table->dropColumn(['pin_hash', 'pin', 'pin_attempts', 'pin_sent_via', 'pin_sent_at']);
+ });
+ }
+};
diff --git a/server/resources/views/mail/inspection-link-pin.blade.php b/server/resources/views/mail/inspection-link-pin.blade.php
new file mode 100644
index 000000000..5c04268bd
--- /dev/null
+++ b/server/resources/views/mail/inspection-link-pin.blade.php
@@ -0,0 +1,34 @@
+@php
+ // Strings are built here, not inline: Blade only reads `@` as a directive
+ // when it does not follow a word character, so `inspection@if(...)` would
+ // stay literal text while its `@endif` compiled and broke the view.
+ $formName = $form?->name ?: 'inspection';
+ $vehicleName = $vehicle ? ($vehicle->display_name ?? $vehicle->name) : null;
+ $forVehicle = $vehicleName ? ' for ' . $vehicleName : '';
+ $senderLine = $sender?->name ? $sender->name . ' has asked you to complete this inspection.' : 'You have been asked to complete this inspection.';
+@endphp
+
+Complete the {{ $formName }} inspection
+
+@if($recipient && $recipient->name)
+Hi {{ $recipient->name }},
+@endif
+
+{{ $senderLine }} It is the {{ $formName }} inspection{{ $forVehicle }}.
+
+@if($url)
+Open the inspection
+@endif
+
+When it asks, enter this PIN:
+
+{{ $pin }}
+
+The link locks after {{ $maxAttempts }} incorrect PINs.@if($expiresAt) It expires on {{ $expiresAt->format('j M Y, H:i T') }}.@endif
+
+@if($url)
+If the button does not work, copy this address into your browser:
{{ $url }}
+@endif
+
+If you were not expecting this, you can ignore it. Do not forward this email: anyone with it can open the inspection.
+
diff --git a/server/src/Auth/Schemas/FleetOps.php b/server/src/Auth/Schemas/FleetOps.php
index 081aabc2f..74bf863ae 100644
--- a/server/src/Auth/Schemas/FleetOps.php
+++ b/server/src/Auth/Schemas/FleetOps.php
@@ -125,6 +125,14 @@ class FleetOps
'name' => 'work-order',
'actions' => ['export', 'import'],
],
+ [
+ 'name' => 'inspection-form',
+ 'actions' => ['publish', 'archive'],
+ ],
+ [
+ 'name' => 'inspection-submission',
+ 'actions' => ['submit', 'create-issue', 'create-work-order', 'resolve'],
+ ],
[
'name' => 'equipment',
'actions' => ['export', 'import'],
@@ -297,6 +305,8 @@ class FleetOps
'see extension',
'* maintenance',
'* work-order',
+ '* inspection-form',
+ '* inspection-submission',
'* equipment',
'* part',
'* trailer',
@@ -322,6 +332,8 @@ class FleetOps
'* place',
'* maintenance',
'* work-order',
+ '* inspection-form',
+ '* inspection-submission',
'* equipment',
'* part',
'* trailer',
diff --git a/server/src/Exports/InspectionExport.php b/server/src/Exports/InspectionExport.php
new file mode 100644
index 000000000..90357ab8a
--- /dev/null
+++ b/server/src/Exports/InspectionExport.php
@@ -0,0 +1,125 @@
+selections = $selections;
+ }
+
+ public function map($submission): array
+ {
+ return [
+ $submission->public_id,
+ $submission->form_name,
+ $submission->vehicle_name,
+ $submission->driver_name,
+ $submission->type,
+ $submission->status,
+ $submission->result,
+ $submission->source,
+ $submission->odometer,
+ $submission->engine_hours,
+ $submission->total_items,
+ $submission->failed_items,
+ static::failedLabels($submission),
+ static::unsafeLabel($submission),
+ $submission->issue?->public_id,
+ $submission->workOrder?->public_id,
+ $submission->started_at,
+ $submission->submitted_at,
+ $submission->resolved_at,
+ $submission->created_at,
+ ];
+ }
+
+ public function headings(): array
+ {
+ return [
+ 'ID',
+ 'Form',
+ 'Vehicle',
+ 'Driver',
+ 'Type',
+ 'Status',
+ 'Result',
+ 'Source',
+ 'Odometer',
+ 'Engine Hours',
+ 'Items',
+ 'Defects',
+ 'Failed Items',
+ 'Unsafe',
+ 'Issue',
+ 'Work Order',
+ 'Started',
+ 'Submitted',
+ 'Resolved',
+ 'Date Created',
+ ];
+ }
+
+ public function columnFormats(): array
+ {
+ return [
+ 'Q' => NumberFormat::FORMAT_DATE_DDMMYYYY,
+ 'R' => NumberFormat::FORMAT_DATE_DDMMYYYY,
+ 'S' => NumberFormat::FORMAT_DATE_DDMMYYYY,
+ 'T' => NumberFormat::FORMAT_DATE_DDMMYYYY,
+ ];
+ }
+
+ /** The defects, named, so the spreadsheet says what actually failed. */
+ public static function failedLabels(InspectionSubmission $submission): string
+ {
+ return $submission->itemResults
+ ->filter(fn ($result) => !$result->passed)
+ ->map(fn ($result) => trim((string) $result->label))
+ ->filter()
+ ->implode(', ');
+ }
+
+ /** Whether the inspection took the vehicle out of service. */
+ public static function unsafeLabel(InspectionSubmission $submission): string
+ {
+ $unsafe = filter_var(data_get($submission->meta, 'unsafe', false), FILTER_VALIDATE_BOOLEAN)
+ || $submission->itemResults->contains(fn ($result) => filter_var(data_get($result->meta, 'unsafe', false), FILTER_VALIDATE_BOOLEAN));
+
+ return $unsafe ? 'Yes' : 'No';
+ }
+
+ /**
+ * @return \Illuminate\Support\Collection
+ */
+ public function collection()
+ {
+ $query = InspectionSubmission::where('company_uuid', session('company'))
+ ->with(['form', 'vehicle', 'driver', 'itemResults', 'issue', 'workOrder']);
+
+ if ($this->selections) {
+ $query->whereIn('uuid', $this->selections);
+ }
+
+ return $query->get();
+ }
+}
diff --git a/server/src/Http/Controllers/Api/v1/GeofenceController.php b/server/src/Http/Controllers/Api/v1/GeofenceController.php
index 433fdd62f..385b72e60 100644
--- a/server/src/Http/Controllers/Api/v1/GeofenceController.php
+++ b/server/src/Http/Controllers/Api/v1/GeofenceController.php
@@ -2,9 +2,9 @@
namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
+use Fleetbase\FleetOps\Models\Driver;
use Fleetbase\FleetOps\Models\GeofenceEventLog;
use Fleetbase\FleetOps\Support\Utils;
-use Fleetbase\FleetOps\Models\Driver;
use Fleetbase\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
diff --git a/server/src/Http/Controllers/Api/v1/InspectionController.php b/server/src/Http/Controllers/Api/v1/InspectionController.php
new file mode 100644
index 000000000..00698f67c
--- /dev/null
+++ b/server/src/Http/Controllers/Api/v1/InspectionController.php
@@ -0,0 +1,300 @@
+filled('type')) {
+ $query->whereIn('type', Utils::arrayFrom($request->input('type')));
+ }
+
+ if ($request->filled('vehicle')) {
+ $vehicle = static::findVehicleRecord($request->input('vehicle'));
+ if (!$vehicle) {
+ return response()->apiError('Vehicle resource not found.', 404);
+ }
+
+ $query->where(function ($query) use ($vehicle) {
+ $query->whereNull('subject_uuid')->orWhere(function ($query) use ($vehicle) {
+ $query->where('subject_type', Vehicle::class)->where('subject_uuid', $vehicle->uuid);
+ });
+ });
+ }
+
+ return InspectionFormResource::collection($query->limit(static::limit($request))->get());
+ }
+
+ /**
+ * GET /v1/inspection-forms/{id} — one published form, items and settings included.
+ *
+ * A draft or archived form answers 404 rather than 403: to a driver a form
+ * that cannot be filled in does not exist, and the distinction would only
+ * tell them something about the console they cannot act on.
+ */
+ public function findForm(string $id)
+ {
+ $form = static::findPublishedForm($id);
+ if (!$form) {
+ return response()->apiError('Inspection form resource not found.', 404);
+ }
+
+ return new InspectionFormResource($form);
+ }
+
+ /**
+ * POST /v1/inspections — file an inspection against a published form.
+ *
+ * The body is exactly what the public link accepts, plus the three things
+ * the link already knew: which form, which vehicle, which driver. The app
+ * queues a submit when it is offline and replays it later, so a replay
+ * that carries the same `Idempotency-Key` header answers with the
+ * submission the first attempt created rather than filing a second one.
+ */
+ public function submit(Request $request)
+ {
+ $validated = $request->validate(array_merge(InspectionSubmitter::rules(), [
+ 'inspection_form' => 'required|string',
+ 'driver' => 'required|string',
+ 'vehicle' => 'nullable|string',
+ 'started_at' => 'nullable|date',
+ ]));
+
+ $driver = static::findDriverRecord($request->input('driver'));
+ if (!$driver) {
+ return response()->apiError('Driver resource not found.', 404);
+ }
+
+ $form = static::findPublishedForm($request->input('inspection_form'));
+ if (!$form) {
+ return response()->apiError('Inspection form resource not found.', 404);
+ }
+
+ $vehicle = null;
+ if ($request->filled('vehicle')) {
+ $vehicle = static::findVehicleRecord($request->input('vehicle'));
+ if (!$vehicle) {
+ return response()->apiError('Vehicle resource not found.', 404);
+ }
+ }
+
+ $idempotencyKey = trim((string) $request->header('Idempotency-Key'));
+ if ($idempotencyKey !== '') {
+ $replayed = static::findSubmissionByIdempotencyKey($driver, $idempotencyKey);
+ if ($replayed) {
+ return new InspectionSubmissionResource($replayed->load(static::SUBMISSION_RELATIONS));
+ }
+ }
+
+ $attributes = [
+ // Without an explicit vehicle the inspection is of the truck the
+ // driver is assigned to, which is what a pre-trip almost always is.
+ 'vehicle_uuid' => $vehicle?->uuid ?? $driver->vehicle_uuid,
+ 'driver_uuid' => $driver->uuid,
+ 'submitted_by_uuid' => $driver->user_uuid,
+ 'source' => 'navigator',
+ 'meta' => $idempotencyKey !== '' ? ['idempotency_key' => $idempotencyKey] : [],
+ ];
+
+ if ($request->filled('started_at')) {
+ $attributes['started_at'] = Carbon::parse($request->input('started_at'));
+ }
+
+ $submission = InspectionSubmitter::submit($form, $validated, $attributes);
+
+ return new InspectionSubmissionResource($submission->fresh(static::SUBMISSION_RELATIONS));
+ }
+
+ /**
+ * GET /v1/inspections — submissions, newest first.
+ */
+ public function query(Request $request)
+ {
+ $query = static::submissions();
+
+ if ($request->filled('driver')) {
+ $driver = static::findDriverRecord($request->input('driver'));
+ if (!$driver) {
+ return response()->apiError('Driver resource not found.', 404);
+ }
+
+ $query->where('driver_uuid', $driver->uuid);
+ }
+
+ if ($request->filled('vehicle')) {
+ $vehicle = static::findVehicleRecord($request->input('vehicle'));
+ if (!$vehicle) {
+ return response()->apiError('Vehicle resource not found.', 404);
+ }
+
+ $query->where('vehicle_uuid', $vehicle->uuid);
+ }
+
+ static::applySubmissionFilters($request, $query);
+
+ return InspectionSubmissionResource::collection($query->limit(static::limit($request))->get());
+ }
+
+ /**
+ * GET /v1/inspections/{id} — one submission with its item results and follow-up.
+ */
+ public function find(string $id)
+ {
+ $submission = static::findSubmission($id);
+ if (!$submission) {
+ return response()->apiError('Inspection resource not found.', 404);
+ }
+
+ return new InspectionSubmissionResource($submission->load(static::SUBMISSION_RELATIONS));
+ }
+
+ /**
+ * GET /v1/vehicles/{id}/inspections — a vehicle's inspection history.
+ *
+ * The same rows `query` answers with `vehicle=`, addressed the way the app
+ * holds them: it is on a vehicle's screen, and wants that vehicle's history.
+ */
+ public function forVehicle(Request $request, string $id)
+ {
+ $vehicle = static::findVehicleRecord($id);
+ if (!$vehicle) {
+ return response()->apiError('Vehicle resource not found.', 404);
+ }
+
+ $query = static::submissions()->where('vehicle_uuid', $vehicle->uuid);
+ static::applySubmissionFilters($request, $query);
+
+ return InspectionSubmissionResource::collection($query->limit(static::limit($request))->get());
+ }
+
+ /** The column filters shared by the two listings. Each accepts one value or a comma separated list. */
+ protected static function applySubmissionFilters(Request $request, $query): void
+ {
+ foreach (['type', 'result', 'status'] as $column) {
+ if ($request->filled($column)) {
+ $query->whereIn($column, Utils::arrayFrom($request->input($column)));
+ }
+ }
+ }
+
+ /**
+ * How many rows a listing answers with. Defaults to what fits on a screen;
+ * a driver's whole history is thousands of rows nobody asked for.
+ */
+ protected static function limit(Request $request): int
+ {
+ $limit = (int) $request->input('limit', 30);
+
+ return $limit > 0 ? $limit : 30;
+ }
+
+ /**
+ * Every lookup is scoped to the company the API key belongs to. The
+ * consumable API serves one company per credential, and a public id from
+ * another company must be indistinguishable from one that does not exist.
+ */
+ protected static function publishedForms()
+ {
+ return InspectionForm::where('company_uuid', session('company'))
+ ->where('status', 'published')
+ ->whereNotNull('published_at')
+ // The form's structure is the point of the read; loading it with
+ // the forms keeps a listing to two queries instead of two a form.
+ ->with(['fieldGroups', 'fields'])
+ ->orderBy('published_at', 'desc');
+ }
+
+ protected static function submissions()
+ {
+ return InspectionSubmission::where('company_uuid', session('company'))
+ ->with(['form', 'vehicle', 'driver', 'customFieldValues.customField', 'files'])
+ ->orderBy('submitted_at', 'desc')
+ ->orderBy('created_at', 'desc');
+ }
+
+ protected static function findPublishedForm(string $id): ?InspectionForm
+ {
+ return static::publishedForms()
+ ->where(function ($query) use ($id) {
+ $query->where('public_id', $id)->orWhere('uuid', $id);
+ })
+ ->first();
+ }
+
+ protected static function findSubmission(string $id): ?InspectionSubmission
+ {
+ return InspectionSubmission::where('company_uuid', session('company'))
+ ->where(function ($query) use ($id) {
+ $query->where('public_id', $id)->orWhere('uuid', $id);
+ })
+ ->first();
+ }
+
+ protected static function findSubmissionByIdempotencyKey(Driver $driver, string $key): ?InspectionSubmission
+ {
+ return InspectionSubmission::where('company_uuid', $driver->company_uuid)
+ ->where('driver_uuid', $driver->uuid)
+ ->where('meta->idempotency_key', $key)
+ ->first();
+ }
+
+ protected static function findDriverRecord(string $id): ?Driver
+ {
+ return Driver::where('company_uuid', session('company'))
+ ->where(function ($query) use ($id) {
+ $query->where('public_id', $id)->orWhere('uuid', $id);
+ })
+ ->first();
+ }
+
+ protected static function findVehicleRecord(string $id): ?Vehicle
+ {
+ return Vehicle::where('company_uuid', session('company'))
+ ->where(function ($query) use ($id) {
+ $query->where('public_id', $id)->orWhere('uuid', $id);
+ })
+ ->first();
+ }
+}
diff --git a/server/src/Http/Controllers/Internal/v1/HubController.php b/server/src/Http/Controllers/Internal/v1/HubController.php
index a8c83e5e0..d1a7c9866 100644
--- a/server/src/Http/Controllers/Internal/v1/HubController.php
+++ b/server/src/Http/Controllers/Internal/v1/HubController.php
@@ -9,6 +9,8 @@
use Fleetbase\FleetOps\Models\Fleet;
use Fleetbase\FleetOps\Models\FuelProviderTransaction;
use Fleetbase\FleetOps\Models\FuelReport;
+use Fleetbase\FleetOps\Models\InspectionForm;
+use Fleetbase\FleetOps\Models\InspectionSubmission;
use Fleetbase\FleetOps\Models\Issue;
use Fleetbase\FleetOps\Models\Maintenance;
use Fleetbase\FleetOps\Models\MaintenanceSchedule;
@@ -112,24 +114,36 @@ public function maintenance(Request $request)
$now = Carbon::now();
$next7 = Carbon::now()->addDays(7);
- $overdueSchedules = $this->count(MaintenanceSchedule::query()->where('status', 'active')->whereNotNull('next_due_date')->where('next_due_date', '<', $now), $company);
- $upcomingSchedules = $this->count(MaintenanceSchedule::query()->where('status', 'active')->whereBetween('next_due_date', [$now, $next7]), $company);
- $openWorkOrders = $this->count(WorkOrder::query()->whereIn('status', ['open', 'in_progress']), $company);
- $overdueWorkOrders = $this->count(WorkOrder::query()->whereNotIn('status', ['closed', 'canceled'])->whereNotNull('due_at')->where('due_at', '<', $now), $company);
- $openMaintenance = $this->count(Maintenance::query()->whereNotIn('status', ['completed', 'canceled']), $company);
- $highPriorityMaintenance = $this->count(Maintenance::query()->whereNotIn('status', ['completed', 'canceled'])->whereIn('priority', ['high', 'urgent', 'critical']), $company);
- $lowStockParts = $this->count(Part::query()->where('quantity_on_hand', '>', 0)->where('quantity_on_hand', '<=', 5), $company);
- $equipment = $this->count(Equipment::query(), $company);
+ $overdueSchedules = $this->count(MaintenanceSchedule::query()->where('status', 'active')->whereNotNull('next_due_date')->where('next_due_date', '<', $now), $company);
+ $upcomingSchedules = $this->count(MaintenanceSchedule::query()->where('status', 'active')->whereBetween('next_due_date', [$now, $next7]), $company);
+ $openWorkOrders = $this->count(WorkOrder::query()->whereIn('status', ['open', 'in_progress']), $company);
+ $overdueWorkOrders = $this->count(WorkOrder::query()->whereNotIn('status', ['closed', 'canceled'])->whereNotNull('due_at')->where('due_at', '<', $now), $company);
+ $openMaintenance = $this->count(Maintenance::query()->whereNotIn('status', ['completed', 'canceled']), $company);
+ $highPriorityMaintenance = $this->count(Maintenance::query()->whereNotIn('status', ['completed', 'canceled'])->whereIn('priority', ['high', 'urgent', 'critical']), $company);
+ $publishedInspectionForms = $this->count(InspectionForm::query()->where('status', 'published'), $company);
+ $failedInspections = $this->count(InspectionSubmission::query()->whereIn('status', ['submitted', 'needs_review'])->where('result', 'failed'), $company);
+ $unresolvedInspections = $this->count(InspectionSubmission::query()->where('result', 'failed')->whereNull('resolved_at'), $company);
+ $lowStockParts = $this->count(Part::query()->where('quantity_on_hand', '>', 0)->where('quantity_on_hand', '<=', 5), $company);
+ $equipment = $this->count(Equipment::query(), $company);
return response()->json([
'kpis' => [
$this->kpi('overdue_schedules', 'Overdue Schedules', $overdueSchedules, $overdueSchedules > 0 ? 'Recurring service needs attention.' : 'No recurring service is overdue.', $overdueSchedules > 0 ? 'rose' : 'green', 'calendar-xmark', 'maintenance.schedules'),
$this->kpi('upcoming_schedules', 'Due This Week', $upcomingSchedules, 'Maintenance schedules due in the next 7 days.', 'blue', 'calendar-day', 'maintenance.schedules'),
+ $this->kpi('failed_inspections', 'Failed Inspections', $failedInspections, $failedInspections > 0 ? 'Failed DVIR items need issue or work order follow-up.' : 'No failed inspections need review.', $failedInspections > 0 ? 'rose' : 'green', 'clipboard-check', 'maintenance.inspection-submissions'),
$this->kpi('open_work_orders', 'Open Work Orders', $openWorkOrders, $openWorkOrders > 0 ? 'Active work needs assignment or closure.' : 'No open work orders right now.', $openWorkOrders > 0 ? 'amber' : 'green', 'clipboard-list', 'maintenance.work-orders'),
- $this->kpi('low_stock_parts', 'Low Stock Parts', $lowStockParts, $lowStockParts > 0 ? 'Parts inventory may need replenishment.' : 'No low-stock parts detected.', $lowStockParts > 0 ? 'amber' : 'green', 'cog', 'maintenance.parts'),
],
- 'actions' => $this->maintenanceActions($overdueSchedules, $upcomingSchedules, $openWorkOrders, $overdueWorkOrders, $highPriorityMaintenance, $lowStockParts, $equipment),
+ 'actions' => $this->maintenanceActions($overdueSchedules, $upcomingSchedules, $openWorkOrders, $overdueWorkOrders, $highPriorityMaintenance, $lowStockParts, $equipment, $failedInspections, $unresolvedInspections, $publishedInspectionForms),
'sections' => [
+ [
+ 'key' => 'inspections',
+ 'title' => 'Inspections And DVIR',
+ 'description' => 'Capture driver inspections, failed items, and repair follow-up.',
+ 'links' => [
+ $this->link('Inspection Forms', 'maintenance.inspection-forms', 'clipboard-check', $publishedInspectionForms, 'Published forms available for driver and technician inspections.'),
+ $this->link('Inspections', 'maintenance.inspection-submissions', 'list-check', $unresolvedInspections, 'Submitted DVIRs, failed items, and linked follow-up work.'),
+ ],
+ ],
[
'key' => 'planning',
'title' => 'Planning',
@@ -152,6 +166,7 @@ public function maintenance(Request $request)
],
'docs' => [
$this->doc('Schedules', 'calendar-alt', 'fleet-ops/maintenance/schedules/overview', 'Maintenance schedules guide', 'Plan recurring service windows and convert due schedules into work orders.'),
+ $this->doc('Inspections', 'clipboard-check', 'fleet-ops/maintenance/inspections/overview', 'Inspections guide', 'Capture DVIR checks, failed items, and issue or work order follow-up.'),
$this->doc('Work Orders', 'clipboard-list', 'fleet-ops/maintenance/work-orders/overview', 'Work orders guide', 'Coordinate assigned maintenance work, vendors, due dates, and completion.'),
$this->doc('Equipment', 'trailer', 'fleet-ops/maintenance/equipment/overview', 'Equipment guide', 'Track serviceable equipment that participates in maintenance operations.'),
$this->doc('Parts', 'cog', 'fleet-ops/maintenance/parts/overview', 'Parts guide', 'Manage parts inventory and restocking signals used by maintenance teams.'),
@@ -231,10 +246,16 @@ protected function resourceActions(array $counts, int $fuelRecords): array
return $actions ? array_slice($actions, 0, 6) : [$this->action('ready', 'Core resources look ready', 'Drivers, vehicles, issues, and supporting records are in a healthy operating posture.', 'success', 'check-circle', null)];
}
- protected function maintenanceActions(int $overdueSchedules, int $upcomingSchedules, int $openWorkOrders, int $overdueWorkOrders, int $highPriorityMaintenance, int $lowStockParts, int $equipment): array
+ protected function maintenanceActions(int $overdueSchedules, int $upcomingSchedules, int $openWorkOrders, int $overdueWorkOrders, int $highPriorityMaintenance, int $lowStockParts, int $equipment, int $failedInspections = 0, int $unresolvedInspections = 0, int $publishedInspectionForms = 0): array
{
$actions = [];
+ if ($failedInspections > 0) {
+ $actions[] = $this->action('failed_inspections', 'Review failed inspections', "{$failedInspections} failed inspection" . ($failedInspections === 1 ? ' needs' : 's need') . ' issue or work order follow-up.', 'warning', 'clipboard-check', 'maintenance.inspection-submissions', ['result' => 'failed']);
+ } elseif ($publishedInspectionForms === 0) {
+ $actions[] = $this->action('create_inspection_forms', 'Create inspection forms', 'Publish DVIR forms so drivers can report vehicle defects before dispatch.', 'info', 'clipboard-check', 'maintenance.inspection-forms');
+ }
+
if ($overdueSchedules > 0) {
$actions[] = $this->action('overdue_schedules', 'Review overdue schedules', "{$overdueSchedules} recurring service schedule" . ($overdueSchedules === 1 ? ' is' : 's are') . ' overdue.', 'warning', 'calendar-xmark', 'maintenance.schedules');
}
@@ -259,6 +280,10 @@ protected function maintenanceActions(int $overdueSchedules, int $upcomingSchedu
$actions[] = $this->action('low_stock_parts', 'Replenish low-stock parts', "{$lowStockParts} stocked part" . ($lowStockParts === 1 ? ' is' : 's are') . ' at or below the default threshold.', 'warning', 'cog', 'maintenance.parts');
}
+ if ($unresolvedInspections > 0 && $failedInspections === 0) {
+ $actions[] = $this->action('unresolved_inspections', 'Resolve inspection follow-up', "{$unresolvedInspections} failed inspection" . ($unresolvedInspections === 1 ? ' remains' : 's remain') . ' unresolved.', 'warning', 'list-check', 'maintenance.inspection-submissions', ['result' => 'failed']);
+ }
+
if ($equipment === 0) {
$actions[] = $this->action('add_equipment', 'Add serviceable equipment', 'Equipment records make maintenance planning more complete.', 'info', 'trailer', 'maintenance.equipment');
}
diff --git a/server/src/Http/Controllers/Internal/v1/InspectionFormController.php b/server/src/Http/Controllers/Internal/v1/InspectionFormController.php
new file mode 100644
index 000000000..b77373a82
--- /dev/null
+++ b/server/src/Http/Controllers/Internal/v1/InspectionFormController.php
@@ -0,0 +1,326 @@
+syncStructureFromRequest($request, $inspectionForm);
+ }
+
+ public function onAfterUpdate(Request $request, InspectionForm $inspectionForm): void
+ {
+ $this->syncStructureFromRequest($request, $inspectionForm);
+ }
+
+ /** The console edits the structure, so a read has to carry it. */
+ public function onFindRecord($builder, $request): void
+ {
+ $builder->with(['fieldGroups', 'fields']);
+ }
+
+ public function onQueryRecord($builder, $request): void
+ {
+ $builder->with(['fieldGroups', 'fields']);
+ }
+
+ /**
+ * Writes the posted structure, pruning what the post no longer lists —
+ * the builder always posts the whole form, so a field it dropped is a
+ * field the author deleted.
+ */
+ protected function syncStructureFromRequest(Request $request, InspectionForm $form): void
+ {
+ $draft = null;
+ foreach (['inspection_form.field_groups', 'field_groups', 'inspection_form.draft', 'draft'] as $key) {
+ $posted = $request->input($key);
+ if (is_array($posted) && !empty($posted)) {
+ $draft = $posted;
+ break;
+ }
+ }
+
+ if ($draft === null) {
+ return;
+ }
+
+ InspectionFormSync::sync($form, $draft, true);
+ $form->unsetRelation('fieldGroups');
+ $form->unsetRelation('fields');
+ $form->load(['fieldGroups', 'fields']);
+ }
+
+ public function publish(string $id): JsonResponse
+ {
+ $form = $this->resolveForm($id)
+ ->firstOrFail();
+
+ $form->publish();
+
+ return response()->json([
+ 'status' => 'ok',
+ 'message' => 'Inspection form published.',
+ 'data' => $form->fresh(),
+ ]);
+ }
+
+ public function archive(string $id): JsonResponse
+ {
+ $form = $this->resolveForm($id)
+ ->firstOrFail();
+
+ $form->archive();
+
+ return response()->json([
+ 'status' => 'ok',
+ 'message' => 'Inspection form archived.',
+ 'data' => $form->fresh(),
+ ]);
+ }
+
+ public function generateLink(Request $request, string $id): JsonResponse
+ {
+ $form = $this->resolveForm($id)
+ ->firstOrFail();
+
+ if (!$form->is_published) {
+ return response()->json([
+ 'error' => 'Inspection form must be published before generating a public link.',
+ ], 422);
+ }
+
+ $validated = $request->validate([
+ 'assignee' => 'nullable|string',
+ 'driver' => 'nullable|string',
+ 'vehicle' => 'nullable|string',
+ 'expires_at' => 'nullable|date|after:now',
+ 'single_use' => 'nullable|boolean',
+ 'pin_delivery' => 'nullable|in:none,email,sms',
+ ]);
+
+ // Who the link is for, and the inspection's driver and vehicle, are
+ // each optional and independent: anyone in the organisation may
+ // complete an inspection, not only a driver.
+ $assignee = $this->resolveAssignee(data_get($validated, 'assignee'));
+ $driver = $this->resolveDriver(data_get($validated, 'driver'));
+ $vehicle = $this->resolveVehicle(data_get($validated, 'vehicle'));
+ $delivery = data_get($validated, 'pin_delivery') ?: 'none';
+ $token = InspectionLink::generateToken();
+
+ // A delivery that cannot happen is refused before anything is minted,
+ // so the dispatcher hears now rather than finding a link nobody got
+ // the PIN for.
+ if ($delivery !== 'none') {
+ $reason = InspectionLinkPin::unavailableReason($assignee ?? $driver?->user, $delivery);
+
+ if ($reason) {
+ return response()->json(['error' => $reason], 422);
+ }
+ }
+
+ $link = InspectionLink::create([
+ 'company_uuid' => $form->company_uuid,
+ 'inspection_form_uuid' => $form->uuid,
+ 'driver_uuid' => $driver?->uuid,
+ 'vehicle_uuid' => $vehicle?->uuid,
+ 'assignee_uuid' => $assignee?->uuid,
+ 'created_by_uuid' => session('user'),
+ 'token_hash' => InspectionLink::hashToken($token),
+ 'token' => $token,
+ 'status' => 'active',
+ 'single_use' => data_get($validated, 'single_use', true),
+ // A link nobody put a limit on used to stay live until it was
+ // used or revoked. It now lasts DEFAULT_TTL_HOURS unless chosen.
+ 'expires_at' => data_get($validated, 'expires_at') ?? now()->addHours(InspectionLink::DEFAULT_TTL_HOURS),
+ ]);
+
+ // Every link minted here carries a PIN. It protects anything only when
+ // it travels a different way from the link, which is why a delivery
+ // sends the PIN and never the link.
+ $pin = InspectionLink::generatePin();
+ $link->setPin($pin);
+ $link->save();
+
+ $delivered = $delivery !== 'none' ? InspectionLinkPin::send($link, $delivery) : null;
+
+ // `~/` is what puts the page outside the console: the host app routes
+ // `/~/:slug` at the top level, a sibling of `console`, so neither the
+ // console's chrome nor its authentication gate applies. Without it the
+ // link lands on the authenticated `console/:slug` route instead, which
+ // bounces a signed-out recipient to the login page and renders blank
+ // for everyone else.
+ $path = '/~/inspection?id=' . urlencode($form->public_id ?? $form->uuid) . '&token=' . urlencode($token);
+
+ return response()->json([
+ 'status' => 'ok',
+ 'message' => 'Inspection link generated.',
+ 'link' => array_merge(
+ (new InspectionLinkResource($link->fresh(['form', 'driver', 'vehicle', 'assignee', 'createdBy'])))->resolve(),
+ ['path' => $path, 'token' => $token, 'pin' => $pin]
+ ),
+ 'pin_delivery' => $delivered,
+ ]);
+ }
+
+ /**
+ * Every link minted for this form, newest first.
+ *
+ * A link used to vanish the moment the modal that minted it closed. An
+ * operator needs to see what is outstanding: which vehicle and driver a
+ * link was for, when it was made, whether it has been opened, and whether
+ * it still works.
+ */
+ public function links(Request $request, string $id): JsonResponse
+ {
+ $form = $this->resolveForm($id)->firstOrFail();
+
+ $links = InspectionLink::where('inspection_form_uuid', $form->uuid)
+ ->with(['form', 'driver', 'vehicle', 'assignee', 'createdBy'])
+ ->orderByDesc('created_at')
+ ->limit((int) $request->input('limit', 50))
+ ->get();
+
+ return response()->json([
+ 'links' => InspectionLinkResource::collection($links)->resolve(),
+ ]);
+ }
+
+ /** Take a link out of use, leaving the record of it in the list. */
+ public function revokeLink(Request $request, string $id, string $linkId): JsonResponse
+ {
+ $form = $this->resolveForm($id)->firstOrFail();
+
+ $link = InspectionLink::where('inspection_form_uuid', $form->uuid)
+ ->where(function ($query) use ($linkId) {
+ $query->where('uuid', $linkId)->orWhere('public_id', $linkId);
+
+ if (is_numeric($linkId)) {
+ $query->orWhere('id', (int) $linkId);
+ }
+ })
+ ->firstOrFail();
+
+ $link->revoke();
+
+ return response()->json([
+ 'status' => 'ok',
+ 'message' => 'Inspection link revoked.',
+ 'link' => (new InspectionLinkResource($link->fresh(['form', 'driver', 'vehicle', 'createdBy'])))->resolve(),
+ ]);
+ }
+
+ /**
+ * Send a link's PIN again, by email or SMS, to whoever the link is for.
+ * A failed delivery answers 200 with `pin_delivery.sent` false and why, so
+ * the console shows it as a warning rather than an error.
+ */
+ public function sendPin(Request $request, string $id, string $linkId): JsonResponse
+ {
+ $form = $this->resolveForm($id)->firstOrFail();
+ $validated = $request->validate(['via' => 'required|in:email,sms']);
+
+ $link = InspectionLink::where('inspection_form_uuid', $form->uuid)
+ ->where(function ($query) use ($linkId) {
+ $query->where('uuid', $linkId)->orWhere('public_id', $linkId);
+ })
+ ->firstOrFail();
+
+ if ($link->state !== 'active') {
+ return response()->json(['error' => 'Only an active link can have its PIN sent.'], 422);
+ }
+
+ $reason = InspectionLinkPin::unavailableReason(InspectionLinkPin::recipientFor($link), $validated['via']);
+
+ if ($reason) {
+ return response()->json(['error' => $reason], 422);
+ }
+
+ $result = InspectionLinkPin::send($link, $validated['via']);
+
+ return response()->json([
+ 'status' => $result['sent'] ? 'ok' : 'error',
+ 'message' => $result['sent'] ? 'PIN sent.' : $result['error'],
+ 'pin_delivery' => $result,
+ 'link' => (new InspectionLinkResource($link->fresh(['form', 'driver', 'vehicle', 'assignee', 'createdBy'])))->resolve(),
+ ]);
+ }
+
+ /** A user a link may be assigned to: only a member of this organisation. */
+ protected function resolveAssignee(?string $id): ?User
+ {
+ if (!$id) {
+ return null;
+ }
+
+ return User::where(function ($query) use ($id) {
+ $query->where('uuid', $id)->orWhere('public_id', $id);
+ })
+ ->whereHas('companyUsers', function ($query) {
+ $query->where('company_uuid', session('company'));
+ })
+ ->firstOrFail();
+ }
+
+ protected function resolveDriver(?string $id): ?Driver
+ {
+ if (!$id) {
+ return null;
+ }
+
+ return Driver::where('company_uuid', session('company'))
+ ->where(function ($query) use ($id) {
+ $query->where('uuid', $id)->orWhere('public_id', $id);
+ })
+ ->firstOrFail();
+ }
+
+ protected function resolveForm(string $id)
+ {
+ return InspectionForm::where('company_uuid', session('company'))
+ ->where(function ($query) use ($id) {
+ $query->where('uuid', $id)->orWhere('public_id', $id);
+ });
+ }
+
+ protected function resolveVehicle(?string $id): ?Vehicle
+ {
+ if (!$id) {
+ return null;
+ }
+
+ return Vehicle::where('company_uuid', session('company'))
+ ->where(function ($query) use ($id) {
+ $query->where('uuid', $id)->orWhere('public_id', $id);
+ })
+ ->firstOrFail();
+ }
+}
diff --git a/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php b/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php
new file mode 100644
index 000000000..441053dbc
--- /dev/null
+++ b/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php
@@ -0,0 +1,229 @@
+submitted_by_uuid && session('user')) {
+ $record->forceFill(['submitted_by_uuid' => session('user')])->save();
+ }
+
+ $this->syncAnswersFromRequest($request, $record);
+ $record->load(array_merge(['submittedBy'], static::RELATIONS));
+ }
+
+ public function onAfterUpdate($request, InspectionSubmission $record, array $input): void
+ {
+ $this->syncAnswersFromRequest($request, $record);
+ $record->load(static::RELATIONS);
+ }
+
+ public function onFindRecord($builder, $request): void
+ {
+ $builder->with(array_merge(['submittedBy', 'issue', 'workOrder'], static::RELATIONS));
+ }
+
+ public function onQueryRecord($builder, $request): void
+ {
+ $builder->with(['form', 'vehicle', 'driver', 'itemResults']);
+ }
+
+ /**
+ * The answers, then the results derived from them.
+ *
+ * A form built from fields is answered with `custom_field_values`, exactly
+ * as the driver API answers it, so the console and the app write the same
+ * rows; the submitter stores any photo or signature and mirrors every
+ * pass-fail answer into an item result. A submission against a legacy
+ * checklist still posts `item_results` directly, and that door stays open.
+ */
+ protected function syncAnswersFromRequest(Request $request, InspectionSubmission $submission): void
+ {
+ $values = static::arrayInput($request, 'inspection_submission.custom_field_values', 'custom_field_values');
+ if (!empty($values)) {
+ InspectionSubmitter::applyCustomFieldValues($submission, $values, session('user'));
+
+ return;
+ }
+
+ $this->syncItemResultsFromRequest($request, $submission);
+ }
+
+ /**
+ * The first of the given keys that carries a list.
+ *
+ * The console posts a record under its resource name and the app posts it
+ * flat, so both spellings are read. `Request::array()` arrived in Laravel
+ * 11 and this runs on 10, where calling it is a BadMethodCallException.
+ *
+ * @return array
+ */
+ protected static function arrayInput(Request $request, string ...$keys): array
+ {
+ foreach ($keys as $key) {
+ $value = $request->input($key);
+ if (is_array($value) && !empty($value)) {
+ return $value;
+ }
+ }
+
+ return [];
+ }
+
+ /**
+ * Export inspections to excel or csv.
+ *
+ * @return \Illuminate\Http\Response
+ */
+ public function export(ExportRequest $request)
+ {
+ $format = $request->input('format', 'xlsx');
+ $selections = static::arrayInput($request, 'selections');
+ $fileName = trim(Str::slug('inspections-' . date('Y-m-d-H:i')) . '.' . $format);
+
+ return $this->downloadExport(new InspectionExport($selections), $fileName);
+ }
+
+ public function submit(string $id): JsonResponse
+ {
+ $submission = InspectionSubmission::where('uuid', $id)
+ ->orWhere('public_id', $id)
+ ->with(['itemResults', 'vehicle', 'driver', 'form'])
+ ->firstOrFail();
+
+ $submission->syncResultCounts();
+
+ return response()->json([
+ 'status' => 'ok',
+ 'message' => 'Inspection submitted.',
+ 'data' => $submission->fresh(['itemResults', 'vehicle', 'driver', 'form']),
+ ]);
+ }
+
+ public function createIssue(string $id): JsonResponse
+ {
+ $submission = InspectionSubmission::where('uuid', $id)
+ ->orWhere('public_id', $id)
+ ->with(['itemResults', 'vehicle', 'driver', 'form'])
+ ->firstOrFail();
+
+ $submission->syncResultCounts();
+ $issue = $submission->createIssueFromFailures();
+
+ return response()->json([
+ 'status' => 'ok',
+ 'message' => $issue ? 'Issue created from failed inspection items.' : 'No failed inspection items found.',
+ 'issue' => $issue,
+ 'data' => $submission->fresh(['itemResults', 'issue']),
+ ]);
+ }
+
+ public function createWorkOrder(string $id): JsonResponse
+ {
+ $submission = InspectionSubmission::where('uuid', $id)
+ ->orWhere('public_id', $id)
+ ->with(['itemResults', 'vehicle', 'driver', 'form'])
+ ->firstOrFail();
+
+ $submission->syncResultCounts();
+ $submission->createIssueFromFailures();
+ $workOrder = $submission->createWorkOrderFromFailures();
+
+ return response()->json([
+ 'status' => 'ok',
+ 'message' => $workOrder ? 'Work order created from failed inspection items.' : 'No failed inspection items found.',
+ 'work_order' => $workOrder,
+ 'data' => $submission->fresh(['itemResults', 'issue', 'workOrder']),
+ ]);
+ }
+
+ public function resolve(string $id): JsonResponse
+ {
+ $submission = InspectionSubmission::where('uuid', $id)
+ ->orWhere('public_id', $id)
+ ->firstOrFail();
+
+ $submission->update([
+ 'status' => 'resolved',
+ 'resolved_at' => now(),
+ ]);
+
+ return response()->json([
+ 'status' => 'ok',
+ 'message' => 'Inspection resolved.',
+ 'data' => $submission->fresh(['itemResults', 'issue', 'workOrder']),
+ ]);
+ }
+
+ protected function syncItemResultsFromRequest(Request $request, InspectionSubmission $submission): void
+ {
+ $items = static::arrayInput($request, 'inspection_submission.item_results', 'item_results');
+ if (empty($items)) {
+ return;
+ }
+
+ $seen = [];
+ foreach ($items as $item) {
+ $uuid = data_get($item, 'uuid');
+ $payload = [
+ 'company_uuid' => $submission->company_uuid,
+ 'inspection_submission_uuid' => $submission->uuid,
+ 'item_key' => data_get($item, 'item_key'),
+ 'label' => data_get($item, 'label', data_get($item, 'title', 'Inspection item')),
+ 'category' => data_get($item, 'category'),
+ 'status' => data_get($item, 'status', data_get($item, 'passed') === false ? 'failed' : 'passed'),
+ 'severity' => data_get($item, 'severity'),
+ 'passed' => (bool) data_get($item, 'passed', data_get($item, 'status') !== 'failed'),
+ 'comments' => data_get($item, 'comments'),
+ 'photos' => data_get($item, 'photos'),
+ 'meta' => data_get($item, 'meta'),
+ ];
+
+ $lookup = [
+ 'inspection_submission_uuid' => $submission->uuid,
+ ];
+
+ if ($uuid) {
+ $lookup['uuid'] = $uuid;
+ } elseif ($payload['item_key']) {
+ $lookup['item_key'] = $payload['item_key'];
+ } else {
+ $lookup['label'] = $payload['label'];
+ }
+
+ $result = InspectionItemResult::updateOrCreate($lookup, $payload);
+ $seen[] = $result->uuid;
+ }
+
+ if (!empty($seen)) {
+ $submission->itemResults()->whereNotIn('uuid', $seen)->delete();
+ }
+
+ $submission->syncResultCounts();
+ }
+}
diff --git a/server/src/Http/Controllers/Public/PublicInspectionController.php b/server/src/Http/Controllers/Public/PublicInspectionController.php
new file mode 100644
index 000000000..d740dd8ed
--- /dev/null
+++ b/server/src/Http/Controllers/Public/PublicInspectionController.php
@@ -0,0 +1,243 @@
+ 'jpg',
+ 'image/png' => 'png',
+ 'image/webp' => 'webp',
+ 'image/gif' => 'gif',
+ 'image/heic' => 'heic',
+ 'image/heif' => 'heif',
+ ];
+
+ public function show(Request $request, string $id): JsonResponse
+ {
+ [$form, $link] = $this->resolvePublishedFormAndLink($request, $id);
+
+ $link->markViewed();
+
+ return response()->json([
+ 'form' => (new InspectionFormResource($form))->resolve(),
+ 'identity' => $this->identityPayload($link),
+ ]);
+ }
+
+ public function submit(Request $request, string $id): JsonResponse
+ {
+ [$form, $link] = $this->resolvePublishedFormAndLink($request, $id);
+
+ $validated = $request->validate(InspectionSubmitter::rules());
+
+ // A link submits through its form's fields. The older flat checklist
+ // stores photo URLs exactly as given, which on a public link would let
+ // anyone put an arbitrary outside image on the record.
+ if (!empty($validated['item_results']) && empty($validated['custom_field_values'])) {
+ abort(response()->json(['error' => 'Submit this inspection through its form fields.'], 422));
+ }
+
+ // A single-use link is claimed before anything is written, inside the
+ // same transaction as the submission: a second submit at the same
+ // moment finds it already taken and writes nothing, and a submission
+ // that fails to save gives the link back.
+ $submission = DB::connection($link->getConnectionName())->transaction(function () use ($form, $link, $validated, $request) {
+ if ($link->single_use && !$link->claim($request->ip(), (string) $request->userAgent())) {
+ abort(response()->json(['error' => 'This inspection link has already been used.'], 409));
+ }
+
+ $submission = InspectionSubmitter::submit($form, $validated, [
+ 'vehicle_uuid' => $link->vehicle_uuid,
+ 'driver_uuid' => $link->driver_uuid,
+ // The person the link is for: its assignee, or the driver's
+ // account. What they typed as their name is kept beside it,
+ // with whether a PIN stood between the link and the form.
+ 'submitted_by_uuid' => InspectionLinkPin::recipientFor($link)?->uuid,
+ 'source' => 'public_link',
+ 'meta' => [
+ 'inspection_link_uuid' => $link->uuid,
+ 'inspection_link_id' => $link->public_id,
+ 'completed_by_name' => data_get($validated, 'signature.name'),
+ 'pin_verified' => $link->hasPin(),
+ ],
+ ]);
+
+ if (!$link->single_use) {
+ $link->markUsed($request->ip(), (string) $request->userAgent());
+ }
+
+ return $submission;
+ });
+
+ return response()->json([
+ 'message' => 'Inspection submitted.',
+ 'submission' => (new InspectionSubmissionResource($submission->fresh(['form', 'vehicle', 'driver', 'itemResults', 'issue', 'workOrder'])))->resolve(),
+ ]);
+ }
+
+ /**
+ * Upload a photo or a signature through the link, before submitting.
+ *
+ * The console uploads through the platform's file endpoint, which needs a
+ * session; a link has none, so this is the link's own. It checks the link
+ * exactly as submitting does, takes images only, caps how many one link
+ * may send, and tags each file with the link it came through — a
+ * submission through a link may only reference files uploaded through
+ * that same link.
+ */
+ public function upload(Request $request, string $id): JsonResponse
+ {
+ [, $link] = $this->resolvePublishedFormAndLink($request, $id);
+
+ $request->validate([
+ 'file' => 'required|file|mimetypes:image/jpeg,image/png,image/webp,image/gif,image/heic,image/heif|max:' . static::MAX_UPLOAD_KB,
+ 'type' => 'nullable|in:' . InspectionFileStore::TYPE_PHOTO . ',' . InspectionFileStore::TYPE_SIGNATURE,
+ ]);
+
+ $already = File::query()
+ ->where('company_uuid', $link->company_uuid)
+ ->where('meta->inspection_link_uuid', $link->uuid)
+ ->count();
+
+ if ($already >= static::MAX_UPLOADS_PER_LINK) {
+ abort(response()->json(['error' => 'This inspection link has reached its upload limit.'], 422));
+ }
+
+ $upload = $request->file('file');
+ $disk = config('filesystems.default');
+ $bucket = config('filesystems.disks.' . $disk . '.bucket', config('filesystems.disks.s3.bucket'));
+ // The stored name's extension comes from the type the server found in
+ // the bytes, never from the name the device sent. Validation checks the
+ // content, so an image that is also valid PHP, uploaded as `photo.php`,
+ // would pass it — and stored under that extension on a web-served local
+ // disk, it would be an invitation to run it.
+ $extension = static::EXTENSIONS[$upload->getMimeType()] ?? 'jpg';
+ $stored = $upload->storeAs('inspections/links/' . $link->uuid, File::randomFileNameFromRequest($request, 'file', $extension), ['disk' => $disk]);
+
+ if ($stored === false) {
+ abort(response()->json(['error' => 'This photo could not be stored.'], 500));
+ }
+
+ // Built here rather than through File::createFromUpload(), which takes
+ // the company and uploader from the session — and a link has none.
+ // The type comes from the bytes the server received, not from the
+ // name the device gave the file.
+ $file = File::create([
+ 'company_uuid' => $link->company_uuid,
+ 'uploader_uuid' => InspectionLinkPin::recipientFor($link)?->uuid,
+ 'original_filename' => $upload->getClientOriginalName(),
+ 'content_type' => $upload->getMimeType(),
+ 'disk' => $disk,
+ 'path' => $stored,
+ 'bucket' => $bucket,
+ 'type' => $request->input('type', InspectionFileStore::TYPE_PHOTO),
+ 'file_size' => $upload->getSize(),
+ 'meta' => ['inspection_link_uuid' => $link->uuid],
+ ]);
+
+ return response()->json([
+ 'file' => [
+ 'id' => $file->public_id,
+ 'url' => $file->url,
+ 'filename' => $file->original_filename,
+ 'content_type' => $file->content_type,
+ ],
+ ]);
+ }
+
+ protected function resolvePublishedFormAndLink(Request $request, string $id): array
+ {
+ $token = (string) $request->query('token', $request->input('token'));
+ if (empty($token)) {
+ abort(response()->json(['error' => 'Inspection token is required.'], 403));
+ }
+
+ $form = InspectionForm::where('uuid', $id)
+ ->orWhere('public_id', $id)
+ ->firstOrFail();
+
+ if (!$form->is_published) {
+ abort(response()->json(['error' => 'This inspection form is not available.'], 403));
+ }
+
+ $link = InspectionLink::where('inspection_form_uuid', $form->uuid)
+ ->where('token_hash', InspectionLink::hashToken($token))
+ ->first();
+
+ if (!$link) {
+ abort(response()->json(['error' => 'Inspection link is invalid.'], 403));
+ }
+
+ if ($link->status === 'locked') {
+ abort(response()->json(['error' => static::LOCKED_MESSAGE, 'locked' => true], 403));
+ }
+
+ if (!$link->isUsable()) {
+ abort(response()->json(['error' => 'Inspection link is expired, revoked, or already used.'], 403));
+ }
+
+ // A link with a PIN answers nothing, not even the form's name, until
+ // the PIN is given. Wrong guesses count against the link and lock it.
+ // Every refusal here is a 403, so the page handles them all one way.
+ // The page sends it as a header, so it stays out of URLs and the
+ // access logs that record them; a `pin` field is accepted as well.
+ switch ($link->verifyPin($request->header('X-Inspection-Pin') ?: $request->input('pin'))) {
+ case 'missing':
+ abort(response()->json(['error' => 'Enter the PIN you were given with this link.', 'pin_required' => true], 403));
+ // no break
+ case 'wrong':
+ abort(response()->json(['error' => 'That PIN is not right.', 'pin_required' => true, 'attempts_left' => $link->pinAttemptsLeft()], 403));
+ // no break
+ case 'locked':
+ abort(response()->json(['error' => static::LOCKED_MESSAGE, 'locked' => true], 403));
+ }
+
+ return [$form, $link];
+ }
+
+ protected function identityPayload(InspectionLink $link): array
+ {
+ return [
+ 'assignee' => $link->assignee ? [
+ 'id' => $link->assignee->public_id,
+ 'name' => $link->assignee->name,
+ ] : null,
+ // Name, not phone number: whoever holds the link needs to know who
+ // it is for, not how to reach them.
+ 'driver' => $link->driver ? [
+ 'id' => $link->driver->public_id,
+ 'name' => $link->driver->name,
+ ] : null,
+ 'vehicle' => $link->vehicle ? [
+ 'id' => $link->vehicle->public_id,
+ 'name' => $link->vehicle->display_name ?? $link->vehicle->name,
+ 'plate_number' => $link->vehicle->plate_number,
+ ] : null,
+ 'expires_at' => $link->expires_at,
+ ];
+ }
+}
diff --git a/server/src/Http/Middleware/ForceJsonResponse.php b/server/src/Http/Middleware/ForceJsonResponse.php
new file mode 100644
index 000000000..c6612d2f6
--- /dev/null
+++ b/server/src/Http/Middleware/ForceJsonResponse.php
@@ -0,0 +1,25 @@
+headers->set('Accept', 'application/json');
+
+ return $next($request);
+ }
+}
diff --git a/server/src/Http/Resources/v1/InspectionForm.php b/server/src/Http/Resources/v1/InspectionForm.php
new file mode 100644
index 000000000..db7ca58f1
--- /dev/null
+++ b/server/src/Http/Resources/v1/InspectionForm.php
@@ -0,0 +1,149 @@
+withCustomFields([
+ 'id' => $this->when($internal, $this->id, $this->public_id),
+ 'uuid' => $this->when($internal, $this->uuid),
+ 'public_id' => $this->when($internal, $this->public_id),
+ 'company_uuid' => $this->when($internal, $this->company_uuid),
+ 'created_by_uuid'=> $this->when($internal, $this->created_by_uuid),
+ 'updated_by_uuid'=> $this->when($internal, $this->updated_by_uuid),
+ 'subject_uuid' => $this->when($internal, $this->subject_uuid),
+ 'subject_type' => $this->when($internal, $this->subject_type ? Utils::toEmberResourceType($this->subject_type) : null),
+ 'subject' => $this->whenLoaded('subject', fn () => $this->setSubjectType($this->transformMorphResource($this->subject))),
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'type' => $this->type,
+ 'status' => $this->status,
+ 'items' => data_get($this, 'items', []),
+ 'grouped_fields' => array_map(fn (Category $group) => static::groupToArray($group, $internal), $this->grouped_fields),
+ 'field_groups' => $this->whenLoaded('fieldGroups', fn () => $this->fieldGroups->map(fn (Category $group) => static::groupToArray($group, $internal, false))->values()->all()),
+ 'fields' => $this->whenLoaded('fields', fn () => $this->fields->map(fn (CustomField $field) => static::fieldToArray($field, $internal))->values()->all()),
+ 'settings' => data_get($this, 'settings', (object) []),
+ 'meta' => data_get($this, 'meta', (object) []),
+ 'subject_name' => $this->subject_name,
+ 'item_count' => $this->item_count,
+ 'is_published' => $this->is_published,
+ 'published_at' => $this->published_at,
+ 'updated_at' => $this->updated_at,
+ 'created_at' => $this->created_at,
+ ]);
+ }
+
+ /**
+ * A field group as the API answers it. The console gets the identifiers it
+ * addresses the category by; the driver gets what it renders.
+ */
+ public static function groupToArray(Category $group, bool $internal, bool $withFields = true): array
+ {
+ $data = [
+ 'id' => $internal ? $group->uuid : ($group->public_id ?? $group->uuid),
+ 'uuid' => $group->uuid,
+ 'name' => $group->name,
+ 'description' => $group->description,
+ 'order' => $group->order === null ? null : (int) $group->order,
+ 'meta' => is_array($group->meta) && !empty($group->meta) ? $group->meta : (object) [],
+ ];
+
+ if ($internal) {
+ $data['public_id'] = $group->public_id;
+ $data['company_uuid'] = $group->company_uuid;
+ $data['owner_uuid'] = $group->owner_uuid;
+ $data['owner_type'] = $group->owner_type;
+ $data['for'] = $group->for;
+ }
+
+ if ($withFields) {
+ $fields = $group->relationLoaded('fields') ? $group->getRelation('fields') : collect();
+ $data['fields'] = collect($fields)->map(fn (CustomField $field) => static::fieldToArray($field, $internal))->values()->all();
+ }
+
+ return $data;
+ }
+
+ /**
+ * A field as the API answers it. A custom field has no public id, so the
+ * uuid is the id on both sides; it is what a submit names the field by.
+ */
+ public static function fieldToArray(CustomField $field, bool $internal): array
+ {
+ $data = [
+ 'id' => $field->uuid,
+ 'uuid' => $field->uuid,
+ 'name' => $field->name,
+ 'label' => $field->label,
+ 'description' => $field->description,
+ 'help_text' => $field->help_text,
+ 'type' => $field->type,
+ // A field written straight into the table — by a seed, or by an
+ // older builder — may carry no component. The type names its own.
+ 'component' => $field->component ?: InspectionFormSync::componentFor((string) $field->type),
+ 'required' => (bool) $field->required,
+ 'editable' => $field->editable === null ? true : (bool) $field->editable,
+ 'options' => is_array($field->options) ? array_values($field->options) : [],
+ 'order' => $field->order === null ? null : (int) $field->order,
+ 'meta' => is_array($field->meta) && !empty($field->meta) ? $field->meta : (object) [],
+ ];
+
+ if ($internal) {
+ $data['company_uuid'] = $field->company_uuid;
+ $data['category_uuid'] = $field->category_uuid;
+ $data['subject_uuid'] = $field->subject_uuid;
+ $data['subject_type'] = $field->subject_type;
+ $data['for'] = $field->for;
+ $data['default_value'] = $field->default_value;
+ $data['validation_rules'] = $field->validation_rules;
+ }
+
+ return $data;
+ }
+
+ protected function setSubjectType(?array $resolved): ?array
+ {
+ if (empty($resolved)) {
+ return $resolved;
+ }
+
+ $bareSlug = Str::kebab(class_basename($this->subject_type ?? ''));
+
+ data_set($resolved, 'type', 'maintenance-subject-' . $bareSlug);
+ data_set($resolved, 'subject_type', 'maintenance-subject-' . $bareSlug);
+
+ return $resolved;
+ }
+
+ protected function transformMorphResource($model): ?array
+ {
+ if (!$model) {
+ return null;
+ }
+
+ // Always answers: a model with no resource of its own is served by the
+ // base FleetbaseResource, so there is no second fallback to keep here.
+ $resourceClass = \Fleetbase\Support\Find::httpResourceForModel($model);
+
+ return (new $resourceClass($model))->resolve();
+ }
+}
diff --git a/server/src/Http/Resources/v1/InspectionItemResult.php b/server/src/Http/Resources/v1/InspectionItemResult.php
new file mode 100644
index 000000000..7c4235a3e
--- /dev/null
+++ b/server/src/Http/Resources/v1/InspectionItemResult.php
@@ -0,0 +1,41 @@
+ $this->when(Http::isInternalRequest(), $this->id, $this->uuid),
+ 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid),
+ 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid),
+ 'inspection_submission_uuid' => $this->when(Http::isInternalRequest(), $this->inspection_submission_uuid),
+ 'issue_uuid' => $this->when(Http::isInternalRequest(), $this->issue_uuid),
+ 'work_order_uuid' => $this->when(Http::isInternalRequest(), $this->work_order_uuid),
+ 'item_key' => $this->item_key,
+ 'label' => $this->label,
+ 'category' => $this->category,
+ 'status' => $this->status,
+ 'severity' => $this->severity,
+ 'passed' => $this->passed,
+ 'comments' => $this->comments,
+ 'photos' => data_get($this, 'photos', []),
+ 'meta' => data_get($this, 'meta', Utils::createObject()),
+ 'submission_id' => $this->submission_id,
+ 'updated_at' => $this->updated_at,
+ 'created_at' => $this->created_at,
+ ];
+ }
+}
diff --git a/server/src/Http/Resources/v1/InspectionLink.php b/server/src/Http/Resources/v1/InspectionLink.php
new file mode 100644
index 000000000..7e8cbcc1a
--- /dev/null
+++ b/server/src/Http/Resources/v1/InspectionLink.php
@@ -0,0 +1,84 @@
+id` is the table's auto-increment
+ // column, which no lookup here resolves — a list built from it
+ // could show links but never revoke one.
+ 'id' => $this->public_id,
+ 'uuid' => $this->when($internal, $this->uuid),
+ 'public_id' => $this->when($internal, $this->public_id),
+ 'path' => $this->path,
+ 'state' => $this->state,
+ 'status' => $this->status,
+ 'single_use' => (bool) $this->single_use,
+ 'has_pin' => $this->hasPin(),
+ // Shown in the console, like the link, so a dispatcher can read it
+ // out or send it again. Never on a public request.
+ 'pin' => $this->when($internal, fn () => $this->pin),
+ 'pin_sent_via' => $this->pin_sent_via,
+ 'pin_sent_at' => $this->pin_sent_at,
+ 'pin_attempts' => $this->when($internal, fn () => (int) $this->pin_attempts),
+ 'recipient' => $this->when($internal, function () {
+ $recipient = InspectionLinkPin::recipientFor($this->resource);
+
+ return $recipient ? ['name' => $recipient->name] : null;
+ }),
+ 'can_send_pin' => $this->when($internal, function () {
+ $recipient = InspectionLinkPin::recipientFor($this->resource);
+
+ return [
+ 'email' => InspectionLinkPin::unavailableReason($recipient, 'email') === null,
+ 'sms' => InspectionLinkPin::unavailableReason($recipient, 'sms') === null,
+ ];
+ }),
+ 'assignee' => $this->assignee ? [
+ 'id' => $this->assignee->public_id,
+ 'name' => $this->assignee->name,
+ ] : null,
+ 'driver' => $this->driver ? [
+ 'id' => $this->driver->public_id,
+ 'name' => $this->driver->name,
+ ] : null,
+ 'vehicle' => $this->vehicle ? [
+ 'id' => $this->vehicle->public_id,
+ 'name' => $this->vehicle->display_name ?? $this->vehicle->name,
+ ] : null,
+ 'created_by' => $this->createdBy ? [
+ 'id' => $this->createdBy->public_id,
+ 'name' => $this->createdBy->name,
+ ] : null,
+ 'expires_at' => $this->expires_at,
+ 'last_viewed_at' => $this->last_viewed_at,
+ 'used_at' => $this->used_at,
+ 'created_at' => $this->created_at,
+ ];
+ }
+}
diff --git a/server/src/Http/Resources/v1/InspectionSubmission.php b/server/src/Http/Resources/v1/InspectionSubmission.php
new file mode 100644
index 000000000..f7fefadf9
--- /dev/null
+++ b/server/src/Http/Resources/v1/InspectionSubmission.php
@@ -0,0 +1,160 @@
+withCustomFields([
+ 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id),
+ 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid),
+ 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id),
+ 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid),
+ 'inspection_form_uuid' => $this->when(Http::isInternalRequest(), $this->inspection_form_uuid),
+ 'vehicle_uuid' => $this->when(Http::isInternalRequest(), $this->vehicle_uuid),
+ 'driver_uuid' => $this->when(Http::isInternalRequest(), $this->driver_uuid),
+ 'submitted_by_uuid' => $this->when(Http::isInternalRequest(), $this->submitted_by_uuid),
+ 'issue_uuid' => $this->when(Http::isInternalRequest(), $this->issue_uuid),
+ 'work_order_uuid' => $this->when(Http::isInternalRequest(), $this->work_order_uuid),
+ 'form' => $this->whenLoaded('form', fn () => new InspectionForm($this->form)),
+ 'vehicle' => $this->whenLoaded('vehicle', fn () => new Vehicle($this->vehicle)),
+ 'driver' => $this->whenLoaded('driver', fn () => new Driver($this->driver)),
+ 'submitted_by' => $this->whenLoaded('submittedBy', fn () => new User($this->submittedBy)),
+ 'issue' => $this->whenLoaded('issue', fn () => new Issue($this->issue)),
+ 'work_order' => $this->whenLoaded('workOrder', fn () => new WorkOrder($this->workOrder)),
+ 'item_results' => InspectionItemResult::collection($this->whenLoaded('itemResults')),
+ 'type' => $this->type,
+ 'status' => $this->status,
+ 'result' => $this->result,
+ 'source' => $this->source,
+ 'odometer' => $this->odometer,
+ 'engine_hours' => $this->engine_hours,
+ 'total_items' => $this->total_items,
+ 'failed_items' => $this->failed_items,
+ 'location' => data_get($this, 'location', (object) []),
+ 'signature' => data_get($this, 'signature', (object) []),
+ 'attachments' => data_get($this, 'attachments', []),
+ 'meta' => data_get($this, 'meta', Utils::createObject()),
+ 'form_name' => $this->form_name,
+ 'vehicle_name' => $this->vehicle_name,
+ 'driver_name' => $this->driver_name,
+ 'has_failures' => $this->has_failures,
+ 'started_at' => $this->started_at,
+ 'submitted_at' => $this->submitted_at,
+ 'resolved_at' => $this->resolved_at,
+ 'updated_at' => $this->updated_at,
+ 'created_at' => $this->created_at,
+ ]);
+
+ // The platform's own `withCustomFields` puts the raw value models
+ // under `custom_field_values` for the console. What both consoles and
+ // the app want is the field's identity beside its answer, with file
+ // references resolved, so that projection replaces it.
+ $data['custom_field_values'] = $this->projectCustomFieldValues();
+ $data['files'] = $this->projectFiles();
+
+ return $data;
+ }
+
+ /**
+ * The answers, as the app and the console read them: which field, what it
+ * is called, its type, and the value with every `file:` resolved to
+ * something fetchable.
+ */
+ protected function projectCustomFieldValues(): array
+ {
+ // `withCustomFields()` has already loaded the values and the fields
+ // they answer, so there is nothing to guard against here.
+ $internal = Http::isInternalRequest();
+
+ return collect($this->resource?->customFieldValues)->map(function (CustomFieldValue $value) use ($internal) {
+ $field = $value->customField;
+ $row = [
+ 'custom_field' => $value->custom_field_uuid,
+ 'name' => $field?->name,
+ 'label' => $field?->label ?? $value->custom_field_label,
+ 'type' => $field?->type ?? $value->value_type,
+ 'value_type' => $value->value_type,
+ 'value' => static::projectValue($value),
+ ];
+
+ if ($internal) {
+ $row['uuid'] = $value->uuid;
+ $row['category_uuid'] = $field?->category_uuid;
+ $row['order'] = $field?->order === null ? null : (int) $field->order;
+ $row['meta'] = is_array($field?->meta) && !empty($field->meta) ? $field->meta : (object) [];
+ }
+
+ return $row;
+ })->values()->all();
+ }
+
+ /**
+ * One stored value, with file references resolved. A pass-fail answer
+ * carries its photos inside it, so those are resolved too.
+ */
+ protected static function projectValue(CustomFieldValue $value): mixed
+ {
+ $raw = $value->getRawOriginal('value');
+
+ if (in_array($value->value_type, ['object', 'array'], true)) {
+ $decoded = is_string($raw) ? json_decode($raw, true) : $raw;
+ if (!is_array($decoded)) {
+ return $decoded;
+ }
+
+ if (isset($decoded['photos']) && is_array($decoded['photos'])) {
+ $decoded['photos'] = array_values(array_map(fn ($photo) => InspectionFileStore::project($photo), $decoded['photos']));
+ }
+
+ return $decoded;
+ }
+
+ // A value column is a string, so a meter reading comes back as one.
+ // The app compares and charts these; hand it the number it wrote.
+ if ($value->value_type === 'number') {
+ return is_numeric($raw) ? $raw + 0 : $raw;
+ }
+
+ if ($value->value_type === 'boolean') {
+ return filter_var($raw, FILTER_VALIDATE_BOOLEAN);
+ }
+
+ return InspectionFileStore::project($raw);
+ }
+
+ /** The photos and signatures filed with the inspection. */
+ protected function projectFiles(): array
+ {
+ if (!$this->resource || !$this->resource->relationLoaded('files')) {
+ return [];
+ }
+
+ return $this->resource->files->map(fn (File $file) => [
+ 'id' => $file->public_id ?? $file->uuid,
+ 'uuid' => $file->uuid,
+ 'url' => $file->url,
+ 'original_filename' => $file->original_filename,
+ 'content_type' => $file->content_type,
+ 'type' => $file->type,
+ 'caption' => $file->caption,
+ 'created_at' => $file->created_at,
+ ])->values()->all();
+ }
+}
diff --git a/server/src/Mail/InspectionLinkPinMail.php b/server/src/Mail/InspectionLinkPinMail.php
new file mode 100644
index 000000000..7f98029ba
--- /dev/null
+++ b/server/src/Mail/InspectionLinkPinMail.php
@@ -0,0 +1,57 @@
+link = $link;
+ $this->pin = $pin;
+ $this->recipient = $recipient;
+ $this->url = $url;
+ }
+
+ /** The PIN is kept out of the subject, which a locked phone shows. */
+ public function envelope(): Envelope
+ {
+ return new Envelope(subject: 'Complete the ' . ($this->link->form?->name ?? 'inspection') . ' inspection');
+ }
+
+ public function content(): Content
+ {
+ return new Content(
+ markdown: 'fleetops::mail.inspection-link-pin',
+ with: [
+ 'pin' => $this->pin,
+ 'url' => $this->url,
+ 'recipient' => $this->recipient,
+ 'form' => $this->link->form,
+ 'vehicle' => $this->link->vehicle,
+ 'sender' => $this->link->createdBy,
+ 'expiresAt' => $this->link->expires_at,
+ 'maxAttempts' => InspectionLink::MAX_PIN_ATTEMPTS,
+ ]
+ );
+ }
+}
diff --git a/server/src/Models/InspectionForm.php b/server/src/Models/InspectionForm.php
new file mode 100644
index 000000000..da5eed410
--- /dev/null
+++ b/server/src/Models/InspectionForm.php
@@ -0,0 +1,240 @@
+ Json::class,
+ 'settings' => Json::class,
+ 'meta' => Json::class,
+ 'published_at' => 'datetime',
+ 'subject_type' => PolymorphicType::class,
+ ];
+
+ protected $appends = ['subject_name', 'item_count', 'is_published'];
+ protected $with = ['subject'];
+
+ protected static $logName = 'inspection_form';
+ protected static $logAttributes = '*';
+ protected static $submitEmptyLogs = false;
+
+ public function getActivitylogOptions(): LogOptions
+ {
+ return LogOptions::defaults()->logAll()->logOnlyDirty();
+ }
+
+ public function subject(): MorphTo
+ {
+ return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_uuid');
+ }
+
+ public function submissions(): HasMany
+ {
+ return $this->hasMany(InspectionSubmission::class, 'inspection_form_uuid', 'uuid');
+ }
+
+ public function createdBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'created_by_uuid', 'uuid');
+ }
+
+ public function updatedBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'updated_by_uuid', 'uuid');
+ }
+
+ /**
+ * The groups the form's fields are laid out in: platform categories owned
+ * by the form, in the order the builder put them.
+ */
+ public function fieldGroups(): HasMany
+ {
+ return $this->hasMany(Category::class, 'owner_uuid', 'uuid')
+ ->where('for', static::GROUP_FOR)
+ ->orderByRaw('COALESCE(`order`, 999999), created_at');
+ }
+
+ /**
+ * The fields a driver fills in: platform custom fields whose subject is
+ * the form, filed under the inspection kind so a field attached to the
+ * form record by the console's generic custom-field panel is not one.
+ */
+ public function fields(): HasMany
+ {
+ return $this->hasMany(CustomField::class, 'subject_uuid', 'uuid')
+ ->where('for', static::FIELD_FOR)
+ ->orderByRaw('COALESCE(`order`, 999999), created_at');
+ }
+
+ /**
+ * The form as the driver sees it: every group with its fields inside,
+ * both sorted by `order` then creation, with the fields that belong to
+ * no group gathered into an "Ungrouped" tail.
+ *
+ * @return Category[]
+ */
+ public function getGroupedFieldsAttribute(): array
+ {
+ $this->loadMissing(['fieldGroups', 'fields']);
+
+ $fieldsByGroup = $this->fields->groupBy(fn (CustomField $field) => $field->category_uuid ?: '_ungrouped');
+
+ $grouped = static::sortByOrder($this->fieldGroups)->map(function (Category $group) use ($fieldsByGroup) {
+ $group->setRelation('fields', static::sortByOrder($fieldsByGroup->get($group->uuid, collect())));
+
+ return $group;
+ });
+
+ if ($fieldsByGroup->has('_ungrouped')) {
+ $ungrouped = new Category([
+ 'name' => 'Ungrouped',
+ 'for' => static::GROUP_FOR,
+ 'owner_uuid' => $this->uuid,
+ ]);
+ $ungrouped->exists = false;
+ $ungrouped->setRelation('fields', static::sortByOrder($fieldsByGroup->get('_ungrouped')));
+ $grouped->push($ungrouped);
+ }
+
+ return $grouped->values()->all();
+ }
+
+ /**
+ * Sorts groups or fields the way the builder laid them out: an explicit
+ * `order` first (lowest first), then anything without one in creation order.
+ */
+ public static function sortByOrder(Collection $items): Collection
+ {
+ return $items->sort(function ($a, $b) {
+ $aOrder = $a->order === null ? null : (int) $a->order;
+ $bOrder = $b->order === null ? null : (int) $b->order;
+
+ if ($aOrder !== null && $bOrder !== null && $aOrder !== $bOrder) {
+ return $aOrder <=> $bOrder;
+ }
+
+ if ($aOrder !== null && $bOrder === null) {
+ return -1;
+ }
+
+ if ($aOrder === null && $bOrder !== null) {
+ return 1;
+ }
+
+ return (string) ($a->created_at ?? '') <=> (string) ($b->created_at ?? '');
+ })->values();
+ }
+
+ public function getSubjectNameAttribute(): ?string
+ {
+ return $this->subject?->name ?? $this->subject?->display_name ?? $this->subject?->public_id;
+ }
+
+ /**
+ * How many things a driver answers. Fields once the form has been built
+ * with them; the legacy checklist while it still is one.
+ */
+ public function getItemCountAttribute(): int
+ {
+ $fields = $this->relationLoaded('fields') ? $this->fields->count() : $this->fields()->count();
+ if ($fields > 0) {
+ return $fields;
+ }
+
+ return count($this->items ?? []);
+ }
+
+ public function getIsPublishedAttribute(): bool
+ {
+ return $this->status === 'published' && $this->published_at !== null;
+ }
+
+ public function publish(): bool
+ {
+ return $this->update([
+ 'status' => 'published',
+ 'published_at' => $this->published_at ?? now(),
+ ]);
+ }
+
+ public function archive(): bool
+ {
+ return $this->update(['status' => 'archived']);
+ }
+}
diff --git a/server/src/Models/InspectionItemResult.php b/server/src/Models/InspectionItemResult.php
new file mode 100644
index 000000000..0700c5b9f
--- /dev/null
+++ b/server/src/Models/InspectionItemResult.php
@@ -0,0 +1,90 @@
+ 'boolean',
+ 'photos' => Json::class,
+ 'meta' => Json::class,
+ ];
+
+ protected $appends = ['submission_id'];
+ protected $with = [];
+
+ protected static $logName = 'inspection_item_result';
+ protected static $logAttributes = '*';
+ protected static $submitEmptyLogs = false;
+
+ public function getActivitylogOptions(): LogOptions
+ {
+ return LogOptions::defaults()->logAll()->logOnlyDirty();
+ }
+
+ public function submission(): BelongsTo
+ {
+ return $this->belongsTo(InspectionSubmission::class, 'inspection_submission_uuid', 'uuid');
+ }
+
+ public function issue(): BelongsTo
+ {
+ return $this->belongsTo(Issue::class, 'issue_uuid', 'uuid');
+ }
+
+ public function workOrder(): BelongsTo
+ {
+ return $this->belongsTo(WorkOrder::class, 'work_order_uuid', 'uuid');
+ }
+
+ public function createdBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'created_by_uuid', 'uuid');
+ }
+
+ public function getSubmissionIdAttribute(): ?string
+ {
+ return $this->submission?->public_id;
+ }
+}
diff --git a/server/src/Models/InspectionLink.php b/server/src/Models/InspectionLink.php
new file mode 100644
index 000000000..7b9978e2b
--- /dev/null
+++ b/server/src/Models/InspectionLink.php
@@ -0,0 +1,284 @@
+ 'encrypted',
+ // Kept so the console can show the PIN again, like the link; the hash
+ // beside it is what a guess is checked against.
+ 'pin' => 'encrypted',
+ 'pin_attempts' => 'integer',
+ 'pin_sent_at' => 'datetime',
+ 'single_use' => 'boolean',
+ 'expires_at' => 'datetime',
+ 'last_viewed_at' => 'datetime',
+ 'used_at' => 'datetime',
+ 'meta' => Json::class,
+ ];
+
+ protected $with = ['form', 'driver', 'vehicle', 'assignee'];
+
+ public static function generateToken(): string
+ {
+ return Str::random(64);
+ }
+
+ public static function hashToken(string $token): string
+ {
+ return hash('sha256', $token);
+ }
+
+ public function form(): BelongsTo
+ {
+ return $this->belongsTo(InspectionForm::class, 'inspection_form_uuid', 'uuid');
+ }
+
+ public function driver(): BelongsTo
+ {
+ return $this->belongsTo(Driver::class, 'driver_uuid', 'uuid');
+ }
+
+ public function vehicle(): BelongsTo
+ {
+ return $this->belongsTo(Vehicle::class, 'vehicle_uuid', 'uuid');
+ }
+
+ /** Whoever in the organisation the link is meant for, if anyone. */
+ public function assignee(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'assignee_uuid', 'uuid');
+ }
+
+ public function createdBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'created_by_uuid', 'uuid');
+ }
+
+ /** A new PIN: six digits from a cryptographically secure source. */
+ public static function generatePin(): string
+ {
+ return str_pad((string) random_int(0, (10 ** static::PIN_LENGTH) - 1), static::PIN_LENGTH, '0', STR_PAD_LEFT);
+ }
+
+ /** Give the link a PIN, and forget any wrong guesses at the last one. */
+ public function setPin(string $pin): void
+ {
+ $this->pin_hash = password_hash($pin, PASSWORD_BCRYPT);
+ $this->pin = $pin;
+ $this->pin_attempts = 0;
+ }
+
+ /** Links minted before PINs existed have none, and ask for none. */
+ public function hasPin(): bool
+ {
+ return !empty($this->pin_hash);
+ }
+
+ public function pinAttemptsLeft(): int
+ {
+ return max(0, static::MAX_PIN_ATTEMPTS - (int) $this->pin_attempts);
+ }
+
+ /**
+ * Check a PIN given for this link: `ok`, `missing`, `wrong` or `locked`.
+ *
+ * A wrong guess is counted with an atomic increment, so guesses made at
+ * the same moment cannot all read the same count, and the link locks when
+ * the count reaches MAX_PIN_ATTEMPTS. A right one clears the count.
+ */
+ public function verifyPin(?string $pin): string
+ {
+ if (!$this->hasPin()) {
+ return 'ok';
+ }
+
+ if ($this->status === 'locked') {
+ return 'locked';
+ }
+
+ $pin = preg_replace('/\D/', '', (string) $pin);
+
+ if ($pin === '') {
+ return 'missing';
+ }
+
+ if (password_verify($pin, $this->pin_hash)) {
+ if ($this->pin_attempts) {
+ $this->forceFill(['pin_attempts' => 0])->save();
+ }
+
+ return 'ok';
+ }
+
+ static::query()->whereKey($this->getKey())->increment('pin_attempts');
+ $this->refresh();
+
+ if ($this->pin_attempts >= static::MAX_PIN_ATTEMPTS) {
+ static::query()->whereKey($this->getKey())->update(['status' => 'locked']);
+ $this->refresh();
+
+ return 'locked';
+ }
+
+ return 'wrong';
+ }
+
+ /**
+ * Why a link cannot be used, or `active` when it can.
+ *
+ * `status` records only what a person did to it — a revoked link is
+ * `revoked` — so expiry and single-use exhaustion have to be read off the
+ * timestamps. A list of links is unreadable without this: an expired link
+ * and a live one both say `active` in the column.
+ */
+ public function getStateAttribute(): string
+ {
+ if ($this->status !== 'active') {
+ return $this->status;
+ }
+
+ if ($this->expires_at && $this->expires_at->isPast()) {
+ return 'expired';
+ }
+
+ if ($this->single_use && $this->used_at) {
+ return 'used';
+ }
+
+ return 'active';
+ }
+
+ /** The path this link opens, or null for a link minted before tokens were kept. */
+ public function getPathAttribute(): ?string
+ {
+ $token = $this->token;
+
+ if (empty($token)) {
+ return null;
+ }
+
+ $form = $this->form;
+
+ return '/~/inspection?id=' . urlencode($form->public_id ?? $form->uuid) . '&token=' . urlencode($token);
+ }
+
+ public function isUsable(): bool
+ {
+ if ($this->status !== 'active') {
+ return false;
+ }
+
+ if ($this->expires_at && $this->expires_at->isPast()) {
+ return false;
+ }
+
+ return !($this->single_use && $this->used_at);
+ }
+
+ public function markViewed(): void
+ {
+ $this->forceFill(['last_viewed_at' => now()])->save();
+ }
+
+ /**
+ * Take a single-use link for one submission, atomically.
+ *
+ * Checking `isUsable()` and then marking the link used once the submission
+ * was saved left a window in which two submits at the same moment could
+ * both pass the check. A claim is one conditional update instead: the
+ * database lets exactly one of them set `used_at`, and the other finds
+ * nothing left to update.
+ */
+ public function claim(?string $ip = null, ?string $userAgent = null): bool
+ {
+ $claimed = static::query()
+ ->whereKey($this->getKey())
+ ->where('status', 'active')
+ ->whereNull('used_at')
+ ->where(function ($query) {
+ $query->whereNull('expires_at')->orWhere('expires_at', '>', now());
+ })
+ ->update(['used_at' => now(), 'used_ip' => $ip, 'used_user_agent' => $userAgent]);
+
+ if ($claimed === 1) {
+ $this->refresh();
+ }
+
+ return $claimed === 1;
+ }
+
+ /** Take a link out of use without deleting the record of it. */
+ public function revoke(): void
+ {
+ $this->forceFill(['status' => 'revoked'])->save();
+ }
+
+ public function markUsed(?string $ip = null, ?string $userAgent = null): void
+ {
+ $this->forceFill([
+ 'used_at' => now(),
+ 'used_ip' => $ip,
+ 'used_user_agent' => $userAgent,
+ ])->save();
+ }
+}
diff --git a/server/src/Models/InspectionSubmission.php b/server/src/Models/InspectionSubmission.php
new file mode 100644
index 000000000..d81721d8a
--- /dev/null
+++ b/server/src/Models/InspectionSubmission.php
@@ -0,0 +1,440 @@
+ 'datetime',
+ 'submitted_at' => 'datetime',
+ 'resolved_at' => 'datetime',
+ 'location' => Json::class,
+ 'signature' => Json::class,
+ 'attachments' => Json::class,
+ 'meta' => Json::class,
+ 'odometer' => 'integer',
+ 'engine_hours' => 'integer',
+ 'total_items' => 'integer',
+ 'failed_items' => 'integer',
+ ];
+
+ /**
+ * The column defaults, restored when a client sends null for them.
+ *
+ * An explicit null in an insert overrides the column's default rather than
+ * falling back to it, and these columns are NOT NULL. The console's model
+ * serialises every attribute, so it sent `total_items: null` and the insert
+ * was refused before the counts could be worked out from the answers.
+ */
+ public const COLUMN_DEFAULTS = [
+ 'type' => 'dvir',
+ 'status' => 'draft',
+ 'total_items' => 0,
+ 'failed_items' => 0,
+ ];
+
+ protected static function booted(): void
+ {
+ static::saving(function (InspectionSubmission $submission) {
+ foreach (static::COLUMN_DEFAULTS as $column => $default) {
+ if ($submission->getAttribute($column) === null) {
+ $submission->setAttribute($column, $default);
+ }
+ }
+ });
+ }
+
+ protected $appends = ['form_name', 'vehicle_name', 'driver_name', 'has_failures'];
+ protected $with = ['form', 'vehicle', 'driver'];
+
+ protected static $logName = 'inspection_submission';
+ protected static $logAttributes = '*';
+ protected static $submitEmptyLogs = false;
+
+ public function getActivitylogOptions(): LogOptions
+ {
+ return LogOptions::defaults()->logAll()->logOnlyDirty();
+ }
+
+ public function form(): BelongsTo
+ {
+ return $this->belongsTo(InspectionForm::class, 'inspection_form_uuid', 'uuid');
+ }
+
+ public function vehicle(): BelongsTo
+ {
+ return $this->belongsTo(Vehicle::class, 'vehicle_uuid', 'uuid');
+ }
+
+ public function driver(): BelongsTo
+ {
+ return $this->belongsTo(Driver::class, 'driver_uuid', 'uuid');
+ }
+
+ public function submittedBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'submitted_by_uuid', 'uuid');
+ }
+
+ public function issue(): BelongsTo
+ {
+ return $this->belongsTo(Issue::class, 'issue_uuid', 'uuid');
+ }
+
+ public function workOrder(): BelongsTo
+ {
+ return $this->belongsTo(WorkOrder::class, 'work_order_uuid', 'uuid');
+ }
+
+ public function itemResults(): HasMany
+ {
+ return $this->hasMany(InspectionItemResult::class, 'inspection_submission_uuid', 'uuid');
+ }
+
+ public function failedItemResults(): HasMany
+ {
+ return $this->itemResults()->where('passed', false);
+ }
+
+ /**
+ * The photos and signatures filed with the inspection: platform files
+ * whose subject is the submission, however they arrived — inside the
+ * driver's submit body, or dropped on the console's Photos panel.
+ */
+ public function files(): HasMany
+ {
+ return $this->hasMany(File::class, 'subject_uuid', 'uuid')->orderBy('created_at');
+ }
+
+ /**
+ * Every `pass-fail` answer, as the row the rest of the platform reads.
+ *
+ * Issues, work orders and the history are all built from
+ * `inspection_item_results`; a form built from fields answers through
+ * custom-field values instead, so each pass-fail value is mirrored into
+ * a result row keyed the way the app keys it — the field's name, or its
+ * uuid when it has none. Rows for pass-fail fields the submission no
+ * longer answers are dropped; rows written any other way (a legacy
+ * checklist, the console's results editor) are left alone.
+ *
+ * @return int the number of result rows derived
+ */
+ public function syncItemResultsFromCustomFieldValues(): int
+ {
+ $this->unsetRelation('customFieldValues');
+ $this->load('customFieldValues.customField');
+
+ $answered = $this->customFieldValues->filter(fn (CustomFieldValue $value) => $value->customField?->type === 'pass-fail');
+ $groups = Category::query()->whereIn('uuid', $answered->map(fn (CustomFieldValue $value) => $value->customField->category_uuid)->filter()->unique()->values())->pluck('name', 'uuid');
+ $unsafe = false;
+ $kept = [];
+
+ foreach ($answered as $value) {
+ /** @var CustomField $field */
+ $field = $value->customField;
+ $answer = is_array($value->value) ? $value->value : [];
+ $notApplicable = filter_var($answer['not_applicable'] ?? false, FILTER_VALIDATE_BOOLEAN);
+ $passed = $notApplicable || filter_var($answer['passed'] ?? true, FILTER_VALIDATE_BOOLEAN);
+ $failed = !$passed;
+ $isUnsafe = $failed && filter_var($answer['unsafe'] ?? data_get($field->meta, 'unsafe_on_fail', false), FILTER_VALIDATE_BOOLEAN);
+ $unsafe = $unsafe || $isUnsafe;
+ $itemKey = static::itemKeyFor($field);
+
+ InspectionItemResult::updateOrCreate([
+ 'inspection_submission_uuid' => $this->uuid,
+ 'item_key' => $itemKey,
+ ], [
+ 'company_uuid' => $this->company_uuid,
+ 'label' => $field->label ?? $field->name,
+ 'category' => $groups->get($field->category_uuid) ?? data_get($field->meta, 'category'),
+ 'status' => $notApplicable ? 'not_applicable' : ($passed ? 'passed' : 'failed'),
+ 'severity' => $failed ? ($answer['severity'] ?? data_get($field->meta, 'severity')) : null,
+ 'passed' => $passed,
+ 'comments' => $answer['comments'] ?? null,
+ 'photos' => array_values(array_filter((array) ($answer['photos'] ?? []), 'is_string')),
+ 'meta' => [
+ 'custom_field_uuid' => $field->uuid,
+ 'not_applicable' => $notApplicable,
+ 'unsafe' => $isUnsafe,
+ ],
+ ]);
+
+ $kept[] = $itemKey;
+ }
+
+ $stale = CustomField::query()
+ ->where('subject_uuid', $this->inspection_form_uuid)
+ ->where('for', InspectionForm::FIELD_FOR)
+ ->where('type', 'pass-fail')
+ ->get()
+ ->map(fn (CustomField $field) => static::itemKeyFor($field))
+ ->diff($kept)
+ ->values();
+ if ($stale->isNotEmpty()) {
+ $this->itemResults()->whereIn('item_key', $stale)->delete();
+ }
+
+ $meta = $this->meta ?? [];
+ if (($meta['unsafe'] ?? null) !== $unsafe) {
+ $this->update(['meta' => array_merge($meta, ['unsafe' => $unsafe])]);
+ }
+
+ InspectionFileStore::attachReferenced($this, $this->referencedFileUuids());
+ $this->unsetRelation('itemResults');
+
+ return count($kept);
+ }
+
+ /**
+ * The key a pass-fail field's result row is filed under. The app derives
+ * the same key (`field.name ?? field.id`), so a result the app built and
+ * one the server derived name the same item.
+ */
+ public static function itemKeyFor(CustomField $field): string
+ {
+ return $field->name ?: $field->uuid;
+ }
+
+ /**
+ * Every `file:` the submission's values point at: file and
+ * signature values, and the photos inside a failed pass-fail answer.
+ *
+ * @return string[]
+ */
+ public function referencedFileUuids(): array
+ {
+ $uuids = [];
+ foreach ($this->customFieldValues as $value) {
+ $raw = $value->getRawOriginal('value');
+ $candidates = is_array($value->value) ? (array) ($value->value['photos'] ?? []) : [$raw];
+ foreach ($candidates as $candidate) {
+ $uuid = InspectionFileStore::referencedUuid($candidate);
+ if ($uuid) {
+ $uuids[] = $uuid;
+ }
+ }
+ }
+
+ return array_values(array_unique($uuids));
+ }
+
+ public function getFormNameAttribute(): ?string
+ {
+ return $this->form?->name;
+ }
+
+ public function getVehicleNameAttribute(): ?string
+ {
+ return $this->vehicle?->display_name ?? $this->vehicle?->name;
+ }
+
+ public function getDriverNameAttribute(): ?string
+ {
+ return $this->driver?->name;
+ }
+
+ public function getHasFailuresAttribute(): bool
+ {
+ return (int) $this->failed_items > 0 || $this->result === 'failed';
+ }
+
+ public function syncResultCounts(): bool
+ {
+ $total = $this->itemResults()->count();
+ $failed = $this->failedItemResults()->count();
+
+ return $this->update([
+ 'total_items' => $total,
+ 'failed_items' => $failed,
+ 'result' => $failed > 0 ? 'failed' : 'passed',
+ 'status' => $this->status === 'draft' ? 'submitted' : $this->status,
+ 'submitted_at' => $this->submitted_at ?? now(),
+ ]);
+ }
+
+ public function createIssueFromFailures(): ?Issue
+ {
+ if (!$this->has_failures || $this->issue_uuid) {
+ return $this->issue;
+ }
+
+ $failedLabels = $this->failedItemResults()->limit(6)->pluck('label')->filter()->values()->all();
+ $issue = Issue::create([
+ 'company_uuid' => $this->company_uuid,
+ 'reported_by_uuid' => $this->submitted_by_uuid,
+ 'vehicle_uuid' => $this->vehicle_uuid,
+ 'driver_uuid' => $this->driver_uuid,
+ 'type' => 'inspection',
+ 'category' => 'inspection_failed',
+ 'location' => $this->failureLocation(),
+ 'title' => 'Failed inspection: ' . ($this->vehicle_name ?? $this->public_id),
+ 'report' => empty($failedLabels) ? 'Inspection failed.' : 'Failed items: ' . implode(', ', $failedLabels),
+ 'priority' => $this->highestFailureSeverity(),
+ 'status' => 'pending',
+ 'meta' => [
+ 'inspection_submission_uuid' => $this->uuid,
+ 'inspection_submission_id' => $this->public_id,
+ 'inspection_form_uuid' => $this->inspection_form_uuid,
+ 'failed_items' => $failedLabels,
+ ],
+ ]);
+
+ $this->update(['issue_uuid' => $issue->uuid]);
+
+ return $issue;
+ }
+
+ /**
+ * Where the failure was reported, for the issue it raises.
+ *
+ * `issues.location` is a spatial column with no default, so an insert that
+ * leaves it out is refused outright — MySQL 1364, which reached a driver
+ * filing a failed inspection as a 500. The submission's own coordinates
+ * come first, then the vehicle's last known position, then the driver's,
+ * and an empty point when nothing is known.
+ */
+ public function failureLocation(): Point
+ {
+ // Read here rather than through Utils::getPointFromMixed(), which
+ // throws when it cannot resolve a point: every source below is
+ // routinely empty, and an inspection must not fail for want of one.
+ foreach ([$this->location, $this->vehicle?->location, $this->driver?->location] as $candidate) {
+ if ($candidate instanceof Point) {
+ return $candidate;
+ }
+
+ $latitude = data_get($candidate, 'latitude', data_get($candidate, 'lat'));
+ $longitude = data_get($candidate, 'longitude', data_get($candidate, 'lng'));
+
+ if (is_numeric($latitude) && is_numeric($longitude)) {
+ return new Point((float) $latitude, (float) $longitude);
+ }
+ }
+
+ return new Point(0, 0);
+ }
+
+ public function createWorkOrderFromFailures(): ?WorkOrder
+ {
+ if (!$this->has_failures || $this->work_order_uuid) {
+ return $this->workOrder;
+ }
+
+ $failedItems = $this->failedItemResults()->get();
+ $checklist = $failedItems->map(fn (InspectionItemResult $item) => [
+ 'title' => $item->label,
+ 'required' => true,
+ 'completed' => false,
+ 'source' => 'inspection',
+ 'item_key' => $item->item_key,
+ 'severity' => $item->severity,
+ 'created_at' => now(),
+ ])->values()->all();
+
+ $workOrder = WorkOrder::create([
+ 'company_uuid' => $this->company_uuid,
+ 'subject' => 'Inspection repair: ' . ($this->vehicle_name ?? $this->public_id),
+ 'status' => 'open',
+ 'priority' => $this->highestFailureSeverity(),
+ 'target_type' => $this->vehicle_uuid ? Vehicle::class : null,
+ 'target_uuid' => $this->vehicle_uuid,
+ 'opened_at' => now(),
+ 'due_at' => now()->addDays($this->highestFailureSeverity() === 'critical' ? 1 : 7),
+ 'instructions' => 'Resolve failed inspection items and record completion details.',
+ 'checklist' => $checklist,
+ 'currency' => $this->vehicle?->currency,
+ 'created_by_uuid' => $this->submitted_by_uuid,
+ 'meta' => [
+ 'source' => 'inspection',
+ 'inspection_submission_uuid' => $this->uuid,
+ 'inspection_submission_id' => $this->public_id,
+ 'issue_uuid' => $this->issue_uuid,
+ ],
+ ]);
+
+ $this->update(['work_order_uuid' => $workOrder->uuid]);
+ $this->failedItemResults()->update(['work_order_uuid' => $workOrder->uuid]);
+
+ return $workOrder;
+ }
+
+ public function highestFailureSeverity(): string
+ {
+ $severity = $this->failedItemResults()
+ ->pluck('severity')
+ ->map(fn ($value) => Str::slug((string) $value))
+ ->filter()
+ ->all();
+
+ foreach (['critical', 'high', 'medium', 'low'] as $candidate) {
+ if (in_array($candidate, $severity, true)) {
+ return $candidate;
+ }
+ }
+
+ return $this->has_failures ? 'high' : 'low';
+ }
+}
diff --git a/server/src/Models/WorkOrder.php b/server/src/Models/WorkOrder.php
index 2cfd2cdcf..f6688816a 100644
--- a/server/src/Models/WorkOrder.php
+++ b/server/src/Models/WorkOrder.php
@@ -75,6 +75,7 @@ class WorkOrder extends Model
*/
protected $fillable = [
'company_uuid',
+ 'schedule_uuid',
'code',
'subject',
'category',
@@ -97,6 +98,8 @@ class WorkOrder extends Model
'cost_center',
'budget_code',
'meta',
+ 'created_by_uuid',
+ 'updated_by_uuid',
];
/**
diff --git a/server/src/Observers/WorkOrderObserver.php b/server/src/Observers/WorkOrderObserver.php
index 17dc6e125..7a715d150 100644
--- a/server/src/Observers/WorkOrderObserver.php
+++ b/server/src/Observers/WorkOrderObserver.php
@@ -61,7 +61,7 @@ protected function createMaintenanceRecord(WorkOrder $workOrder): void
'maintainable_type' => $workOrder->target_type,
'maintainable_uuid' => $workOrder->target_uuid,
'type' => 'scheduled',
- 'status' => 'done',
+ 'status' => 'completed',
'priority' => $workOrder->priority,
'scheduled_at' => $workOrder->opened_at,
'completed_at' => $workOrder->closed_at ?? now(),
diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php
index d150a1132..613c74578 100644
--- a/server/src/Providers/FleetOpsServiceProvider.php
+++ b/server/src/Providers/FleetOpsServiceProvider.php
@@ -218,6 +218,11 @@ public function registerMorphMap(): void
'\\Fleetbase\\Models\\Vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class,
'fleet-ops:vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class,
'fleet-ops:trailer' => \Fleetbase\FleetOps\Models\Trailer::class,
+ // A photo or signature filed with an inspection is a platform file
+ // whose subject is the submission. The console names that subject
+ // the way Ember names it, so the alias has to resolve here.
+ 'fleet-ops:inspection-submission' => \Fleetbase\FleetOps\Models\InspectionSubmission::class,
+ 'fleet-ops:inspection-form' => \Fleetbase\FleetOps\Models\InspectionForm::class,
]);
}
diff --git a/server/src/Rules/Base64OrUrl.php b/server/src/Rules/Base64OrUrl.php
new file mode 100644
index 000000000..2516b0432
--- /dev/null
+++ b/server/src/Rules/Base64OrUrl.php
@@ -0,0 +1,54 @@
+`, which
+ * is what every value leaves here as — a URL, or a reference to a file that
+ * already exists, is kept as it came.
+ *
+ * A reference is only ever kept for a file the submission may use: one that
+ * belongs to the submission's own company. A submission through a public
+ * link may go further than that only in one direction — it may reference
+ * nothing but files uploaded through that same link, and it may not use an
+ * outside URL as a photo at all.
+ */
+class InspectionFileStore
+{
+ public const TYPE_PHOTO = 'inspection_photo';
+ public const TYPE_SIGNATURE = 'inspection_signature';
+
+ /**
+ * Normalizes one file value. Returns the value unchanged when it is not a
+ * string, is empty, or is something other than base64 or a file reference.
+ */
+ public static function normalize(mixed $value, InspectionSubmission $submission, string $type = self::TYPE_PHOTO, ?string $uploaderUuid = null): mixed
+ {
+ if (!is_string($value) || trim($value) === '') {
+ return $value;
+ }
+
+ $value = trim($value);
+
+ $linkUuid = static::linkUuidOf($submission);
+
+ // A reference to a file that already exists — `file:`, a bare
+ // uuid, or the public id an upload answers with — is kept only for a
+ // file the submission may use.
+ if (Str::startsWith($value, 'file:') || Str::isUuid($value) || Str::startsWith($value, 'file_')) {
+ return static::ownedReference($value, $submission, $linkUuid);
+ }
+
+ if (static::isUrl($value)) {
+ if ($linkUuid) {
+ throw ValidationException::withMessages(['photos' => 'A photo on an inspection link must be uploaded through the link.']);
+ }
+
+ return $value;
+ }
+
+ if (static::isBase64($value)) {
+ $file = static::store($value, $submission, $type, $uploaderUuid);
+
+ return $file ? 'file:' . $file->uuid : $value;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Stores a base64 payload — bare or as a data URI — as a file that belongs
+ * to the submission, under `inspections//`.
+ */
+ public static function store(string $base64, InspectionSubmission $submission, string $type = self::TYPE_PHOTO, ?string $uploaderUuid = null): ?File
+ {
+ $contentType = null;
+ $data = $base64;
+
+ if (preg_match('/^data:(?[^;]+);base64,(?.+)$/s', $base64, $matches)) {
+ $contentType = $matches['content_type'];
+ $data = $matches['data'];
+ }
+
+ $data = preg_replace('/\s+/', '', $data);
+ $contentType = $contentType ?? static::sniffContentType($data);
+ $extension = static::extensionFor($contentType);
+ $fileName = Str::lower(Str::random(20)) . '.' . $extension;
+
+ $file = File::createFromBase64($data, $fileName, 'inspections/' . $submission->uuid, $type, $contentType);
+ if (!$file instanceof File) {
+ return null;
+ }
+
+ $file->company_uuid = $submission->company_uuid;
+ $file->uploader_uuid = $uploaderUuid ?? $submission->submitted_by_uuid ?? $file->uploader_uuid;
+ $file->setSubject($submission, $type);
+
+ return $file;
+ }
+
+ /**
+ * Files referenced by `file:` values that were uploaded before the
+ * submission existed — the console uploads a photo as soon as it is picked
+ * — are claimed by the submission so its Photos panel lists them.
+ *
+ * @param string[] $uuids
+ */
+ public static function attachReferenced(InspectionSubmission $submission, array $uuids): int
+ {
+ $uuids = array_values(array_unique(array_filter($uuids, 'is_string')));
+ if (empty($uuids)) {
+ return 0;
+ }
+
+ // Only the submission's own company's files, and — for a submission
+ // through a public link — only files uploaded through that link. This
+ // claim was unscoped: any unattached file anywhere whose uuid appeared
+ // in an answer was taken.
+ $query = File::query()
+ ->whereIn('uuid', $uuids)
+ ->where('company_uuid', $submission->company_uuid)
+ ->whereNull('subject_uuid');
+
+ if ($linkUuid = static::linkUuidOf($submission)) {
+ $query->where('meta->inspection_link_uuid', $linkUuid);
+ }
+
+ return $query->update(['subject_uuid' => $submission->uuid, 'subject_type' => $submission->getMorphClass()]);
+ }
+
+ /**
+ * The inspection link a submission came through, or null when it came
+ * through the console or the driver app.
+ */
+ protected static function linkUuidOf(InspectionSubmission $submission): ?string
+ {
+ if ($submission->source !== 'public_link') {
+ return null;
+ }
+
+ $uuid = data_get($submission->meta, 'inspection_link_uuid');
+
+ return is_string($uuid) && $uuid !== '' ? $uuid : null;
+ }
+
+ /**
+ * A reference to an existing file, as `file:`, kept only when the
+ * file is the submission's to use.
+ *
+ * The lookup used to be unscoped, so a submission that named another
+ * company's file — by uuid or public id — kept the reference, the file was
+ * attached to it, and the submission resource then handed out that file's
+ * URL. It is now scoped to the submission's company; a submission through
+ * a public link must also have uploaded the file through that link, and is
+ * refused outright rather than silently losing a photo it named.
+ */
+ protected static function ownedReference(string $value, InspectionSubmission $submission, ?string $linkUuid): ?string
+ {
+ $reference = Str::startsWith($value, 'file:') ? substr($value, 5) : $value;
+
+ $file = File::query()
+ ->where('company_uuid', $submission->company_uuid)
+ ->where(Str::isUuid($reference) ? 'uuid' : 'public_id', $reference)
+ ->first();
+
+ if ($file && (!$linkUuid || data_get($file->meta, 'inspection_link_uuid') === $linkUuid)) {
+ return 'file:' . $file->uuid;
+ }
+
+ if ($linkUuid) {
+ throw ValidationException::withMessages(['photos' => 'A photo on an inspection link must be uploaded through the link.']);
+ }
+
+ return null;
+ }
+
+ /** The uuid a `file:` value points at, or null for anything else. */
+ public static function referencedUuid(mixed $value): ?string
+ {
+ if (!is_string($value) || !Str::startsWith($value, 'file:')) {
+ return null;
+ }
+
+ $uuid = substr($value, 5);
+
+ return Str::isUuid($uuid) ? $uuid : null;
+ }
+
+ /** Resolves a `file:` value to its file, or null. */
+ public static function resolve(mixed $value): ?File
+ {
+ $uuid = static::referencedUuid($value);
+
+ return $uuid ? File::query()->where('uuid', $uuid)->first() : null;
+ }
+
+ /**
+ * What the driver API answers for a file value: the file's id and where to
+ * fetch it, or the value as it was stored when it is a URL.
+ */
+ public static function project(mixed $value): mixed
+ {
+ $file = static::resolve($value);
+ if (!$file) {
+ return $value;
+ }
+
+ return [
+ 'id' => $file->public_id,
+ 'url' => $file->url,
+ 'filename' => $file->original_filename,
+ 'content_type' => $file->content_type,
+ ];
+ }
+
+ public static function isUrl(string $value): bool
+ {
+ return (bool) preg_match('#^https?://#i', $value);
+ }
+
+ /** Bare base64 of at least a few bytes: long enough not to be a word, and only base64 characters. */
+ public static function isBase64(string $value): bool
+ {
+ if (Str::startsWith($value, 'data:')) {
+ return (bool) preg_match('/^data:[^;]+;base64,[A-Za-z0-9+\/=\s]+$/s', $value);
+ }
+
+ $stripped = preg_replace('/\s+/', '', $value);
+
+ return strlen($stripped) >= 16
+ && strlen($stripped) % 4 === 0
+ && (bool) preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $stripped);
+ }
+
+ /** The image type from the decoded bytes' signature; PNG when it is not a known image. */
+ public static function sniffContentType(string $base64): string
+ {
+ $bytes = (string) base64_decode(substr($base64, 0, 32), true);
+
+ if (str_starts_with($bytes, "\xFF\xD8\xFF")) {
+ return 'image/jpeg';
+ }
+
+ if (str_starts_with($bytes, 'GIF8')) {
+ return 'image/gif';
+ }
+
+ if (str_starts_with($bytes, 'RIFF') && substr($bytes, 8, 4) === 'WEBP') {
+ return 'image/webp';
+ }
+
+ if (str_starts_with($bytes, '%PDF')) {
+ return 'application/pdf';
+ }
+
+ return 'image/png';
+ }
+
+ public static function extensionFor(string $contentType): string
+ {
+ return match (strtolower($contentType)) {
+ 'image/jpeg', 'image/jpg' => 'jpg',
+ 'image/gif' => 'gif',
+ 'image/webp' => 'webp',
+ 'application/pdf' => 'pdf',
+ 'image/svg+xml' => 'svg',
+ default => 'png',
+ };
+ }
+}
diff --git a/server/src/Support/InspectionFormSync.php b/server/src/Support/InspectionFormSync.php
new file mode 100644
index 000000000..905bc4edd
--- /dev/null
+++ b/server/src/Support/InspectionFormSync.php
@@ -0,0 +1,231 @@
+where(['owner_uuid' => $form->uuid, 'for' => InspectionForm::GROUP_FOR])
+ ->get()
+ ->keyBy('uuid');
+ // Only the fields a driver answers. A custom field the console's
+ // generic panel added to the form *record* has the same subject and
+ // would otherwise be pruned as a field the builder no longer lists.
+ $existingFields = CustomField::query()
+ ->where(['subject_uuid' => $form->uuid, 'for' => InspectionForm::FIELD_FOR])
+ ->get()
+ ->keyBy('uuid');
+
+ $keptGroups = [];
+ $keptFields = [];
+ $now = now();
+
+ foreach (array_values($draft) as $groupIndex => $groupData) {
+ $groupUuid = Arr::get($groupData, $primaryKey);
+ $group = $groupUuid ? $existingGroups->get($groupUuid) : null;
+ $groupUuid = $group?->uuid ?? ($groupUuid ?: (string) Str::uuid());
+ $groupAttrs = static::normalizeRow(Arr::only($groupData, static::GROUP_COLUMNS), $groupIndex);
+
+ if ($group) {
+ DB::table('categories')->where('uuid', $groupUuid)->update(array_merge($groupAttrs, ['updated_at' => $now]));
+ } else {
+ DB::table('categories')->insert(array_merge($groupAttrs, [
+ 'uuid' => $groupUuid,
+ 'public_id' => 'category_' . Str::lower(Str::random(14)),
+ 'company_uuid' => $form->company_uuid,
+ 'owner_uuid' => $form->uuid,
+ 'owner_type' => $form->getMorphClass(),
+ 'for' => InspectionForm::GROUP_FOR,
+ 'slug' => Str::slug((string) ($groupAttrs['name'] ?? 'group')),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]));
+ }
+
+ $keptGroups[] = $groupUuid;
+
+ $fields = Arr::get($groupData, 'fields', Arr::get($groupData, 'customFields', []));
+ foreach (array_values(is_array($fields) ? $fields : []) as $fieldIndex => $fieldData) {
+ $fieldUuid = Arr::get($fieldData, $primaryKey);
+ $field = $fieldUuid ? $existingFields->get($fieldUuid) : null;
+ $fieldUuid = $field?->uuid ?? ($fieldUuid ?: (string) Str::uuid());
+ $fieldAttrs = static::normalizeField(Arr::only($fieldData, static::FIELD_COLUMNS), $fieldIndex);
+
+ if ($field) {
+ DB::table('custom_fields')->where('uuid', $fieldUuid)->update(array_merge($fieldAttrs, ['category_uuid' => $groupUuid, 'updated_at' => $now]));
+ } else {
+ DB::table('custom_fields')->insert(array_merge($fieldAttrs, [
+ 'uuid' => $fieldUuid,
+ 'company_uuid' => $form->company_uuid,
+ 'category_uuid' => $groupUuid,
+ 'subject_uuid' => $form->uuid,
+ 'subject_type' => $form->getMorphClass(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]));
+ }
+
+ $keptFields[] = $fieldUuid;
+ }
+ }
+
+ if ($pruneMissing) {
+ static::prune($form, $existingGroups, $keptGroups, $existingFields, $keptFields);
+ }
+
+ $form->unsetRelation('fieldGroups');
+ $form->unsetRelation('fields');
+ $form->load(['fieldGroups', 'fields']);
+
+ return ['groups' => $form->fieldGroups->all(), 'fields' => $form->fields->all()];
+ }
+
+ /**
+ * Converts the first cut's `items` checklist into a "Checklist" group of
+ * pass-fail fields. Skips forms that have already been built with fields,
+ * and forms with nothing to convert, so it can be run any number of times.
+ *
+ * @return int the number of fields written
+ */
+ public static function convertLegacyItems(InspectionForm $form): int
+ {
+ $items = array_values(array_filter(is_array($form->items) ? $form->items : [], 'is_array'));
+ if (empty($items) || $form->fields()->count() > 0) {
+ return 0;
+ }
+
+ $fields = [];
+ foreach ($items as $index => $item) {
+ $label = trim((string) ($item['label'] ?? $item['title'] ?? ''));
+ $label = $label === '' ? 'Item ' . ($index + 1) : $label;
+ $fields[] = [
+ 'label' => $label,
+ 'name' => Str::slug((string) ($item['key'] ?? $label)),
+ 'type' => 'pass-fail',
+ 'required' => (bool) ($item['required'] ?? true),
+ 'description' => $item['description'] ?? null,
+ 'order' => $index + 1,
+ 'meta' => [
+ 'severity' => $item['severity'] ?? 'medium',
+ 'category' => $item['category'] ?? null,
+ 'require_photo_on_fail' => false,
+ 'require_comment_on_fail' => false,
+ 'unsafe_on_fail' => ($item['severity'] ?? null) === 'critical',
+ ],
+ ];
+ }
+
+ $synced = static::sync($form, [[
+ 'name' => 'Checklist',
+ 'order' => 1,
+ 'meta' => ['grid_size' => 1, 'converted_from_items' => true],
+ 'fields' => $fields,
+ ]]);
+
+ return count($synced['fields']);
+ }
+
+ /** Group attributes as the categories table stores them. */
+ protected static function normalizeRow(array $attributes, int $index): array
+ {
+ if (!array_key_exists('order', $attributes) || $attributes['order'] === null) {
+ $attributes['order'] = $index + 1;
+ }
+
+ foreach ($attributes as $key => $value) {
+ if (is_array($value) || is_object($value)) {
+ $attributes[$key] = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ }
+ }
+
+ return $attributes;
+ }
+
+ /** Field attributes as the custom_fields table stores them. */
+ protected static function normalizeField(array $attributes, int $index): array
+ {
+ $label = trim((string) ($attributes['label'] ?? ''));
+ if ($label === '') {
+ $label = 'Untitled field';
+ }
+
+ $attributes['label'] = $label;
+ $attributes['name'] = Str::slug((string) (($attributes['name'] ?? '') ?: $label));
+ $attributes['type'] = in_array($attributes['type'] ?? null, InspectionForm::FIELD_TYPES, true) ? $attributes['type'] : 'input';
+ $attributes['for'] = InspectionForm::FIELD_FOR;
+
+ if (empty($attributes['component'])) {
+ $attributes['component'] = static::componentFor($attributes['type']);
+ }
+
+ foreach (['required', 'editable'] as $flag) {
+ $attributes[$flag] = (int) filter_var($attributes[$flag] ?? ($flag === 'editable'), FILTER_VALIDATE_BOOLEAN);
+ }
+
+ return static::normalizeRow($attributes, $index);
+ }
+
+ /**
+ * The console component that edits a field type. The platform's own
+ * types keep the platform's components; the inspection-only types are
+ * rendered by FleetOps and named after themselves.
+ */
+ public static function componentFor(string $type): string
+ {
+ return $type === 'radio-button' ? 'radio-button-select' : $type;
+ }
+
+ protected static function prune(InspectionForm $form, Collection $existingGroups, array $keptGroups, Collection $existingFields, array $keptFields): void
+ {
+ $groupsToDelete = $existingGroups->keys()->diff($keptGroups)->values();
+ if ($groupsToDelete->isNotEmpty()) {
+ CustomField::query()
+ ->where(['subject_uuid' => $form->uuid, 'for' => InspectionForm::FIELD_FOR])
+ ->whereIn('category_uuid', $groupsToDelete)
+ ->delete();
+ Category::query()->whereIn('uuid', $groupsToDelete)->delete();
+ }
+
+ $fieldsToDelete = $existingFields->keys()->diff($keptFields)->values();
+ if ($fieldsToDelete->isNotEmpty()) {
+ CustomField::query()->whereIn('uuid', $fieldsToDelete)->delete();
+ }
+ }
+}
diff --git a/server/src/Support/InspectionLinkPin.php b/server/src/Support/InspectionLinkPin.php
new file mode 100644
index 000000000..2a8aeca63
--- /dev/null
+++ b/server/src/Support/InspectionLinkPin.php
@@ -0,0 +1,150 @@
+assignee ?? $link->driver?->user;
+ }
+
+ /** Why a PIN cannot go to this person this way, or null when it can. */
+ public static function unavailableReason(?User $recipient, string $via): ?string
+ {
+ if (!$recipient) {
+ return 'Assign the link to someone, or pick a driver, to send the PIN to.';
+ }
+
+ if ($via === 'sms' && !filled($recipient->phone)) {
+ return ($recipient->name ?: 'This person') . ' has no phone number to text the PIN to.';
+ }
+
+ if ($via === 'email' && !filled($recipient->email)) {
+ return ($recipient->name ?: 'This person') . ' has no email address to send the PIN to.';
+ }
+
+ return null;
+ }
+
+ /**
+ * Send the link's PIN. Never throws: a failed delivery is reported in the
+ * result, so minting a link does not fail because a mailer is misconfigured.
+ *
+ * @return array{sent: bool, via: string, to: ?string, error: ?string}
+ */
+ public static function send(InspectionLink $link, string $via): array
+ {
+ $recipient = static::recipientFor($link);
+ $reason = static::unavailableReason($recipient, $via);
+
+ if (!$link->pin) {
+ $reason = 'This link has no PIN to send.';
+ }
+
+ if ($reason) {
+ return ['sent' => false, 'via' => $via, 'to' => null, 'error' => $reason];
+ }
+
+ try {
+ if ($via === 'sms') {
+ // Resolved from the container rather than built here, so the
+ // provider can be swapped out without touching this class.
+ $result = app(SmsService::class)->send($recipient->phone, static::smsText($link, $link->pin, static::urlFor($link)), static::smsOptions($link));
+
+ if (is_array($result) && array_key_exists('success', $result) && !$result['success']) {
+ return ['sent' => false, 'via' => $via, 'to' => null, 'error' => 'The PIN could not be texted: ' . ($result['error'] ?? $result['message'] ?? 'the SMS provider refused it.')];
+ }
+
+ $to = static::maskPhone($recipient->phone);
+ } else {
+ Mail::to($recipient)->send(new InspectionLinkPinMail($link, $link->pin, $recipient, static::urlFor($link)));
+ $to = static::maskEmail($recipient->email);
+ }
+ } catch (\Throwable $e) {
+ report($e);
+
+ return ['sent' => false, 'via' => $via, 'to' => null, 'error' => 'The PIN could not be sent: ' . $e->getMessage()];
+ }
+
+ $link->forceFill(['pin_sent_via' => $via, 'pin_sent_at' => now()])->save();
+
+ return ['sent' => true, 'via' => $via, 'to' => $to, 'error' => null];
+ }
+
+ /**
+ * The link as the recipient opens it, on the console's own host. Null for a
+ * link minted before tokens were kept, whose URL cannot be rebuilt.
+ */
+ public static function urlFor(InspectionLink $link): ?string
+ {
+ $path = $link->path;
+
+ return $path ? Utils::consoleUrl($path) : null;
+ }
+
+ /** Naming the organisation so it is recognised, and short enough to read at a glance. */
+ public static function smsText(InspectionLink $link, string $pin, ?string $url = null): string
+ {
+ $company = Company::select(['uuid', 'name'])->find($link->company_uuid)?->name ?? config('app.name');
+ $form = $link->form?->name ?? 'inspection';
+
+ if ($url) {
+ return "{$company}: complete the {$form} inspection at {$url} using PIN {$pin}. Do not share this PIN.";
+ }
+
+ return "{$company}: your PIN for the {$form} inspection is {$pin}. Do not share this PIN.";
+ }
+
+ /**
+ * The organisation's alphanumeric sender ID, when it has one, the same way
+ * the platform's own verification texts use it: as a Twilio-only option,
+ * so other providers are not handed a sender they would reject.
+ */
+ protected static function smsOptions(InspectionLink $link): array
+ {
+ $company = Company::select(['uuid', 'options'])->find($link->company_uuid);
+
+ if (!$company) {
+ return [];
+ }
+
+ $enabled = Utils::castBoolean($company->getOption('alpha_numeric_sender_id_enabled'));
+ $senderId = $company->getOption('alpha_numeric_sender_id');
+
+ return $enabled && $senderId ? ['twilioParams' => ['from' => $senderId]] : [];
+ }
+
+ /** "r•••@fleetbase.io": enough to recognise, not enough to copy. */
+ public static function maskEmail(string $email): string
+ {
+ [$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
+
+ return mb_substr($local, 0, 1) . '•••@' . $domain;
+ }
+
+ /** "•••1969": the last four digits. */
+ public static function maskPhone(string $phone): string
+ {
+ return '•••' . substr(preg_replace('/\D/', '', $phone), -4);
+ }
+}
diff --git a/server/src/Support/InspectionSubmitter.php b/server/src/Support/InspectionSubmitter.php
new file mode 100644
index 000000000..489d5ada2
--- /dev/null
+++ b/server/src/Support/InspectionSubmitter.php
@@ -0,0 +1,286 @@
+ 'nullable|integer|min:0',
+ 'engine_hours' => 'nullable|integer|min:0',
+ // Every key of an answer needs a rule of its own. validate() returns
+ // only keys that have one, and with nested rules present an array's
+ // other keys are dropped: without these, `value` never reached the
+ // submitter, so every answer arrived empty and a failed check
+ // carrying its photo was refused for having none.
+ 'custom_field_values' => 'required_without:item_results|array',
+ 'custom_field_values.*.custom_field' => 'required_without:custom_field_values.*.custom_field_uuid|string|max:191',
+ 'custom_field_values.*.custom_field_uuid' => 'nullable|string|max:191',
+ 'custom_field_values.*.value' => 'nullable',
+ 'custom_field_values.*.value_type' => 'nullable|string|max:50',
+ 'item_results' => 'required_without:custom_field_values|array',
+ 'item_results.*.item_key' => 'nullable|string|max:191',
+ 'item_results.*.label' => 'required|string|max:255',
+ 'item_results.*.category' => 'nullable|string|max:191',
+ 'item_results.*.status' => 'nullable|string|max:50',
+ 'item_results.*.severity' => 'nullable|string|max:50',
+ 'item_results.*.passed' => 'required|boolean',
+ 'item_results.*.comments' => 'nullable|string|max:2000',
+ 'item_results.*.photos' => 'nullable|array',
+ 'item_results.*.photos.*' => ['string', new Base64OrUrl()],
+ 'location' => 'nullable|array',
+ 'signature' => 'nullable|array',
+ 'attachments' => 'nullable|array',
+ ];
+ }
+
+ /**
+ * @param array $validated the request body, already validated against rules()
+ * @param array $attributes columns the door owns: vehicle_uuid, driver_uuid,
+ * submitted_by_uuid, source, started_at, meta
+ */
+ public static function submit(InspectionForm $form, array $validated, array $attributes = []): InspectionSubmission
+ {
+ $submission = InspectionSubmission::create(array_merge([
+ 'company_uuid' => $form->company_uuid,
+ 'inspection_form_uuid' => $form->uuid,
+ 'type' => $form->type ?? 'dvir',
+ 'status' => 'submitted',
+ 'odometer' => data_get($validated, 'odometer'),
+ 'engine_hours' => data_get($validated, 'engine_hours'),
+ 'started_at' => now(),
+ 'submitted_at' => now(),
+ 'location' => data_get($validated, 'location'),
+ 'signature' => data_get($validated, 'signature'),
+ 'attachments' => data_get($validated, 'attachments'),
+ ], $attributes));
+
+ $values = data_get($validated, 'custom_field_values');
+ if (is_array($values) && !empty($values)) {
+ static::applyCustomFieldValues($submission, $values, $submission->submitted_by_uuid);
+ } else {
+ foreach ((array) data_get($validated, 'item_results', []) as $item) {
+ InspectionItemResult::create([
+ 'company_uuid' => $form->company_uuid,
+ 'inspection_submission_uuid' => $submission->uuid,
+ 'item_key' => data_get($item, 'item_key'),
+ 'label' => data_get($item, 'label'),
+ 'category' => data_get($item, 'category'),
+ 'status' => data_get($item, 'status', data_get($item, 'passed') ? 'passed' : 'failed'),
+ 'severity' => data_get($item, 'severity'),
+ 'passed' => (bool) data_get($item, 'passed'),
+ 'comments' => data_get($item, 'comments'),
+ 'photos' => data_get($item, 'photos'),
+ ]);
+ }
+ }
+
+ $submission->syncResultCounts();
+
+ if (data_get($form->settings, 'create_issue_on_failure') && $submission->has_failures) {
+ $submission->createIssueFromFailures();
+ }
+
+ if (data_get($form->settings, 'create_work_order_on_failure') && $submission->has_failures) {
+ $submission->createWorkOrderFromFailures();
+ }
+
+ return $submission;
+ }
+
+ /**
+ * Stores a set of answers against the submission's form.
+ *
+ * Each row names a field of the form by uuid (or name), carries a value
+ * and, optionally, a value type. A field that does not belong to the form,
+ * or a failed pass-fail answer missing the comment or photo the field
+ * insists on, refuses the whole set with a 422 so nothing half-filled is
+ * written. Base64 photos and signatures become platform files on the way
+ * in; when the answers are in, the item results and the counts follow.
+ *
+ * @param array $rows [{custom_field|custom_field_uuid, value, value_type}]
+ * @param string|null $uploaderUuid the user a stored photo or signature is credited to
+ *
+ * @throws ValidationException
+ */
+ public static function applyCustomFieldValues(InspectionSubmission $submission, array $rows, ?string $uploaderUuid = null): array
+ {
+ $fields = CustomField::query()->where('subject_uuid', $submission->inspection_form_uuid)->where('for', InspectionForm::FIELD_FOR)->get();
+ $payload = [];
+ $errors = [];
+
+ foreach (array_values($rows) as $index => $row) {
+ $key = Arr::get($row, 'custom_field', Arr::get($row, 'custom_field_uuid'));
+ $field = static::findField($fields, $key);
+ if (!$field) {
+ $errors["custom_field_values.{$index}.custom_field"] = ['The field "' . (is_scalar($key) ? $key : '?') . '" does not belong to this inspection form.'];
+ continue;
+ }
+
+ [$value, $valueType, $fieldErrors] = static::normalizeValue($field, Arr::get($row, 'value'), Arr::get($row, 'value_type'), $submission, $uploaderUuid);
+ foreach ($fieldErrors as $message) {
+ $errors["custom_field_values.{$index}.value"][] = $message;
+ }
+
+ $payload[] = [
+ 'custom_field_uuid' => $field->uuid,
+ 'value' => $value,
+ 'value_type' => $valueType,
+ ];
+ }
+
+ if (!empty($errors)) {
+ throw ValidationException::withMessages($errors);
+ }
+
+ // `custom_field_values.value` is a NOT NULL column, so an answer that
+ // is nothing is not an answer: it clears whatever was there instead.
+ $summary = $submission->syncCustomFieldValues($payload, ['treat_null_as_delete' => true]);
+ $submission->syncItemResultsFromCustomFieldValues();
+ $submission->syncResultCounts();
+
+ return $summary;
+ }
+
+ /** Finds a field of the form by uuid or, failing that, by its name. */
+ protected static function findField(Collection $fields, mixed $key): ?CustomField
+ {
+ if (!is_string($key) || $key === '') {
+ return null;
+ }
+
+ return $fields->first(fn (CustomField $field) => $field->uuid === $key)
+ ?? $fields->first(fn (CustomField $field) => $field->name === $key);
+ }
+
+ /**
+ * The value as it is stored, its type, and anything wrong with it.
+ *
+ * @return array{0: mixed, 1: string, 2: string[]}
+ */
+ protected static function normalizeValue(CustomField $field, mixed $value, mixed $valueType, InspectionSubmission $submission, ?string $uploaderUuid): array
+ {
+ $errors = [];
+
+ switch ($field->type) {
+ case 'pass-fail':
+ $answer = static::passFailAnswer($value);
+ $failed = !$answer['not_applicable'] && !filter_var($answer['passed'], FILTER_VALIDATE_BOOLEAN);
+ $meta = is_array($field->meta) ? $field->meta : [];
+ $photos = array_values(array_filter($answer['photos'], 'is_string'));
+
+ // What the field insists on is checked before anything is
+ // stored, so a refused answer leaves no orphan photo behind.
+ if ($failed) {
+ if (data_get($meta, 'require_comment_on_fail') && trim((string) $answer['comments']) === '') {
+ $errors[] = 'A comment is required when "' . $field->label . '" fails.';
+ }
+ if (data_get($meta, 'require_photo_on_fail') && empty($photos)) {
+ $errors[] = 'A photo is required when "' . $field->label . '" fails.';
+ }
+ }
+
+ if (!empty($errors)) {
+ return [$answer, 'object', $errors];
+ }
+
+ $answer['photos'] = array_values(array_map(
+ fn ($photo) => InspectionFileStore::normalize($photo, $submission, InspectionFileStore::TYPE_PHOTO, $uploaderUuid),
+ $photos
+ ));
+
+ if ($failed) {
+ $answer['severity'] = $answer['severity'] ?? data_get($meta, 'severity');
+ $answer['unsafe'] = filter_var($answer['unsafe'] ?? data_get($meta, 'unsafe_on_fail', false), FILTER_VALIDATE_BOOLEAN);
+ } else {
+ $answer['severity'] = null;
+ $answer['unsafe'] = false;
+ }
+
+ return [$answer, 'object', $errors];
+
+ case 'file-upload':
+ case 'signature':
+ $type = $field->type === 'signature' ? InspectionFileStore::TYPE_SIGNATURE : InspectionFileStore::TYPE_PHOTO;
+
+ return [InspectionFileStore::normalize($value, $submission, $type, $uploaderUuid), 'file', $errors];
+
+ case 'number':
+ return [$value === null || $value === '' ? null : (is_numeric($value) ? $value + 0 : $value), 'number', $errors];
+
+ case 'boolean':
+ return [filter_var($value, FILTER_VALIDATE_BOOLEAN), 'boolean', $errors];
+
+ default:
+ if (is_array($value)) {
+ return [$value, 'array', $errors];
+ }
+
+ return [$value, is_string($valueType) && $valueType !== '' ? $valueType : 'text', $errors];
+ }
+ }
+
+ /**
+ * A pass-fail answer in one shape, whatever the client sent: an object,
+ * its JSON, a bare boolean, or the words "pass" / "fail".
+ *
+ * "Not applicable" is an answer of its own — the app sends it as
+ * `passed: true, not_applicable: true`, and it must not read as a pass
+ * once it is a result row, or a tail lift a rigid does not have would
+ * count towards the vehicle's clean record.
+ */
+ public static function passFailAnswer(mixed $value): array
+ {
+ if (is_string($value) && Str::startsWith(trim($value), '{')) {
+ $decoded = json_decode($value, true);
+ $value = is_array($decoded) ? $decoded : $value;
+ }
+
+ if (!is_array($value)) {
+ $passed = is_string($value) ? !in_array(Str::lower(trim($value)), ['fail', 'failed', 'false', '0', 'no'], true) : (bool) $value;
+ $value = ['passed' => $passed];
+ }
+
+ $notApplicable = filter_var($value['not_applicable'] ?? $value['na'] ?? false, FILTER_VALIDATE_BOOLEAN)
+ || (array_key_exists('passed', $value) && $value['passed'] === null);
+
+ return [
+ 'passed' => $notApplicable ? true : filter_var($value['passed'] ?? $value['pass'] ?? true, FILTER_VALIDATE_BOOLEAN),
+ 'not_applicable' => $notApplicable,
+ 'severity' => isset($value['severity']) ? (Str::slug((string) $value['severity']) ?: null) : null,
+ 'comments' => isset($value['comments']) ? (string) $value['comments'] : null,
+ 'photos' => is_array($value['photos'] ?? null) ? $value['photos'] : [],
+ 'unsafe' => $value['unsafe'] ?? null,
+ ];
+ }
+}
diff --git a/server/src/Support/Reporting/FleetOpsReportSchema.php b/server/src/Support/Reporting/FleetOpsReportSchema.php
index 7def10879..dc83928ff 100644
--- a/server/src/Support/Reporting/FleetOpsReportSchema.php
+++ b/server/src/Support/Reporting/FleetOpsReportSchema.php
@@ -38,6 +38,11 @@ public function registerReportSchema(ReportSchemaRegistry $registry): void
// Register Fuel Reports table
$registry->registerTable($this->createFuelReportsTable());
+
+ // Register Maintenance tables
+ $registry->registerTable($this->createWorkOrdersTable());
+ $registry->registerTable($this->createMaintenancesTable());
+ $registry->registerTable($this->createInspectionSubmissionsTable());
}
/**
@@ -892,4 +897,177 @@ protected function createFuelReportsTable(): Table
]),
]);
}
+
+ /**
+ * Create the Work Orders table definition.
+ */
+ protected function createWorkOrdersTable(): Table
+ {
+ return Table::make('work_orders')
+ ->label('Work Orders')
+ ->description('Maintenance work orders, assignments, budgets, and lifecycle status')
+ ->category('Maintenance')
+ ->extension('fleet-ops')
+ ->excludeColumns(['uuid', 'deleted_at', 'meta', 'checklist', 'cost_breakdown'])
+ ->maxRows(100000)
+ ->columns([
+ Column::make('public_id', 'string')->label('Work Order ID')->searchable()->filterable()->sortable(),
+ Column::make('code', 'string')->label('Code')->searchable()->filterable()->sortable(),
+ Column::make('subject', 'string')->label('Subject')->searchable()->filterable()->sortable(),
+ Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(),
+ Column::make('priority', 'string')->label('Priority')->filterable()->sortable()->aggregatable(),
+ Column::make('opened_at', 'datetime')->label('Opened At')->filterable()->sortable()->aggregatable(),
+ Column::make('due_at', 'datetime')->label('Due At')->filterable()->sortable()->aggregatable(),
+ Column::make('closed_at', 'datetime')->label('Closed At')->filterable()->sortable()->aggregatable(),
+ Column::make('estimated_cost', 'integer')->label('Estimated Cost')->aggregatable()->sortable(),
+ Column::make('approved_budget', 'integer')->label('Approved Budget')->aggregatable()->sortable(),
+ Column::make('actual_cost', 'integer')->label('Actual Cost')->aggregatable()->sortable(),
+ Column::make('currency', 'string')->label('Currency')->filterable()->aggregatable(),
+ Column::make('cost_center', 'string')->label('Cost Center')->filterable()->sortable(),
+ Column::make('budget_code', 'string')->label('Budget Code')->filterable()->sortable(),
+ Column::make('created_at', 'datetime')->label('Created At')->filterable()->sortable()->aggregatable(),
+ ])
+ ->computedColumns([
+ Column::count('total_work_orders', 'id')->label('Total Work Orders')->description('Count of work orders'),
+ Column::sum('total_actual_cost', 'actual_cost')->label('Total Actual Cost')->description('Sum of completed work order actual cost'),
+ Column::avg('average_actual_cost', 'actual_cost')->label('Average Actual Cost')->description('Average actual work order cost'),
+ ])
+ ->relationships([
+ Relationship::hasAutoJoin('vehicle_target', 'vehicles')
+ ->label('Target Vehicle')
+ ->localKey('target_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('public_id', 'string')->label('Vehicle ID'),
+ Column::make('plate_number', 'string')->label('Plate Number'),
+ Column::make('make', 'string')->label('Make'),
+ Column::make('model', 'string')->label('Model'),
+ Column::make('odometer', 'integer')->label('Odometer'),
+ ]),
+ ]);
+ }
+
+ /**
+ * Create the Maintenances table definition.
+ */
+ protected function createMaintenancesTable(): Table
+ {
+ return Table::make('maintenances')
+ ->label('Maintenance History')
+ ->description('Completed and scheduled maintenance records with labor, parts, tax, and total cost')
+ ->category('Maintenance')
+ ->extension('fleet-ops')
+ ->excludeColumns(['uuid', 'deleted_at', 'meta', 'line_items', 'attachments'])
+ ->maxRows(100000)
+ ->columns([
+ Column::make('public_id', 'string')->label('Maintenance ID')->searchable()->filterable()->sortable(),
+ Column::make('type', 'string')->label('Type')->filterable()->sortable()->aggregatable(),
+ Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(),
+ Column::make('priority', 'string')->label('Priority')->filterable()->sortable()->aggregatable(),
+ Column::make('scheduled_at', 'datetime')->label('Scheduled At')->filterable()->sortable()->aggregatable(),
+ Column::make('started_at', 'datetime')->label('Started At')->filterable()->sortable()->aggregatable(),
+ Column::make('completed_at', 'datetime')->label('Completed At')->filterable()->sortable()->aggregatable(),
+ Column::make('odometer', 'integer')->label('Odometer')->aggregatable()->sortable(),
+ Column::make('engine_hours', 'integer')->label('Engine Hours')->aggregatable()->sortable(),
+ Column::make('summary', 'string')->label('Summary')->searchable()->filterable()->sortable(),
+ Column::make('labor_cost', 'integer')->label('Labor Cost')->aggregatable()->sortable(),
+ Column::make('parts_cost', 'integer')->label('Parts Cost')->aggregatable()->sortable(),
+ Column::make('tax', 'integer')->label('Tax')->aggregatable()->sortable(),
+ Column::make('total_cost', 'integer')->label('Total Cost')->aggregatable()->sortable(),
+ Column::make('currency', 'string')->label('Currency')->filterable()->aggregatable(),
+ Column::make('created_at', 'datetime')->label('Created At')->filterable()->sortable()->aggregatable(),
+ ])
+ ->computedColumns([
+ Column::count('total_maintenance_records', 'id')->label('Total Maintenance Records')->description('Count of maintenance records'),
+ Column::sum('total_maintenance_cost', 'total_cost')->label('Total Maintenance Cost')->description('Sum of maintenance total cost'),
+ Column::avg('average_maintenance_cost', 'total_cost')->label('Average Maintenance Cost')->description('Average maintenance total cost'),
+ Column::sum('total_parts_cost', 'parts_cost')->label('Total Parts Cost')->description('Sum of parts cost'),
+ Column::sum('total_labor_cost', 'labor_cost')->label('Total Labor Cost')->description('Sum of labor cost'),
+ ])
+ ->relationships([
+ Relationship::hasAutoJoin('work_order', 'work_orders')
+ ->label('Work Order')
+ ->localKey('work_order_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('public_id', 'string')->label('Work Order ID'),
+ Column::make('code', 'string')->label('Work Order Code'),
+ Column::make('subject', 'string')->label('Work Order Subject'),
+ ]),
+ Relationship::hasAutoJoin('vehicle', 'vehicles')
+ ->label('Vehicle')
+ ->localKey('maintainable_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('public_id', 'string')->label('Vehicle ID'),
+ Column::make('plate_number', 'string')->label('Plate Number'),
+ Column::make('make', 'string')->label('Make'),
+ Column::make('model', 'string')->label('Model'),
+ Column::make('acquisition_cost', 'integer')->label('Acquisition Cost'),
+ ]),
+ ]);
+ }
+
+ /**
+ * Create the Inspection Submissions table definition.
+ */
+ protected function createInspectionSubmissionsTable(): Table
+ {
+ return Table::make('inspection_submissions')
+ ->label('Inspections')
+ ->description('DVIR and inspection submissions, pass/fail status, and linked maintenance follow-up')
+ ->category('Maintenance')
+ ->extension('fleet-ops')
+ ->excludeColumns(['uuid', 'deleted_at', 'meta', 'location', 'signature', 'attachments'])
+ ->maxRows(100000)
+ ->columns([
+ Column::make('public_id', 'string')->label('Inspection ID')->searchable()->filterable()->sortable(),
+ Column::make('type', 'string')->label('Type')->filterable()->sortable()->aggregatable(),
+ Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(),
+ Column::make('result', 'string')->label('Result')->filterable()->sortable()->aggregatable(),
+ Column::make('source', 'string')->label('Source')->filterable()->sortable()->aggregatable(),
+ Column::make('odometer', 'integer')->label('Odometer')->aggregatable()->sortable(),
+ Column::make('engine_hours', 'integer')->label('Engine Hours')->aggregatable()->sortable(),
+ Column::make('total_items', 'integer')->label('Total Items')->aggregatable()->sortable(),
+ Column::make('failed_items', 'integer')->label('Failed Items')->aggregatable()->sortable(),
+ Column::make('started_at', 'datetime')->label('Started At')->filterable()->sortable()->aggregatable(),
+ Column::make('submitted_at', 'datetime')->label('Submitted At')->filterable()->sortable()->aggregatable(),
+ Column::make('resolved_at', 'datetime')->label('Resolved At')->filterable()->sortable()->aggregatable(),
+ Column::make('created_at', 'datetime')->label('Created At')->filterable()->sortable()->aggregatable(),
+ ])
+ ->computedColumns([
+ Column::count('total_inspections', 'id')->label('Total Inspections')->description('Count of inspection submissions'),
+ Column::sum('total_failed_items', 'failed_items')->label('Total Failed Items')->description('Sum of failed inspection items'),
+ Column::avg('average_failed_items', 'failed_items')->label('Average Failed Items')->description('Average failed items per inspection'),
+ ])
+ ->relationships([
+ Relationship::hasAutoJoin('inspection_form', 'inspection_forms')
+ ->label('Inspection Form')
+ ->localKey('inspection_form_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('name', 'string')->label('Form Name'),
+ Column::make('type', 'string')->label('Form Type'),
+ ]),
+ Relationship::hasAutoJoin('vehicle', 'vehicles')
+ ->label('Vehicle')
+ ->localKey('vehicle_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('public_id', 'string')->label('Vehicle ID'),
+ Column::make('plate_number', 'string')->label('Plate Number'),
+ Column::make('make', 'string')->label('Make'),
+ Column::make('model', 'string')->label('Model'),
+ ]),
+ Relationship::hasAutoJoin('work_order', 'work_orders')
+ ->label('Work Order')
+ ->localKey('work_order_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('public_id', 'string')->label('Work Order ID'),
+ Column::make('status', 'string')->label('Work Order Status'),
+ Column::make('actual_cost', 'integer')->label('Work Order Actual Cost'),
+ ]),
+ ]);
+ }
}
diff --git a/server/src/routes.php b/server/src/routes.php
index ad7f0df65..ddf0efae3 100644
--- a/server/src/routes.php
+++ b/server/src/routes.php
@@ -14,6 +14,18 @@
*/
Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\FleetOps\Http\Controllers')->group(
function ($router) {
+ // Tokenised inspection links, for people with no console login. The
+ // link itself is the credential, so what guards it lives here: a rate
+ // limit per address on every call, and a tighter one on uploads. Each
+ // limiter has its own prefix so the two do not share one counter.
+ // Every answer is JSON, so a refused submission reaches the page with
+ // its reasons instead of as a redirect back to it.
+ $router->prefix('public')->namespace('Public')->middleware([Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class, 'throttle:60,1,inspection-public'])->group(function ($router) {
+ $router->get('inspections/forms/{id}', 'PublicInspectionController@show');
+ $router->post('inspections/forms/{id}/submit', 'PublicInspectionController@submit');
+ $router->post('inspections/forms/{id}/files', 'PublicInspectionController@upload')->middleware('throttle:20,1,inspection-upload');
+ });
+
/*
|--------------------------------------------------------------------------
| Consumable FleetOps API Routes
@@ -207,6 +219,20 @@ function ($router) {
$router->patch('{id}', 'ManifestController@updateStop');
$router->post('{id}', 'ManifestController@updateStop');
});
+ // inspections — a driver's DVIR. Read the published forms and file
+ // against them; authoring forms and reviewing submissions is fleet
+ // management and stays on the internal namespace. A refused submit
+ // answers 422 in JSON: without ForceJsonResponse, a client that did
+ // not ask for JSON was redirected instead.
+ $router->group(['prefix' => 'inspection-forms', 'middleware' => [Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class]], function () use ($router) {
+ $router->get('/', 'InspectionController@queryForms');
+ $router->get('{id}', 'InspectionController@findForm');
+ });
+ $router->group(['prefix' => 'inspections', 'middleware' => [Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class]], function () use ($router) {
+ $router->post('/', 'InspectionController@submit');
+ $router->get('/', 'InspectionController@query');
+ $router->get('{id}', 'InspectionController@find');
+ });
// entities routes
$router->group(['prefix' => 'entities'], function () use ($router) {
@@ -300,6 +326,8 @@ function ($router) {
$router->delete('{id}', 'VehicleController@delete');
$router->match(['put', 'patch', 'post'], '{id}/track', 'VehicleController@track');
$router->get('{id}/trailers', 'TrailerController@vehicleTrailers');
+ // A vehicle's inspection history, for the driver app's vehicle screen.
+ $router->get('{id}/inspections', 'InspectionController@forVehicle')->middleware(Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class);
});
// trailer routes
$router->group(['prefix' => 'trailers'], function () use ($router) {
@@ -642,6 +670,26 @@ function ($router, $controller) {
$router->get('calendar-feed', $controller('calendarFeed'));
$router->get('{id}/ical', $controller('ical'));
});
+ // The console validates these with $request->validate(), which
+ // redirects a request that did not ask for JSON; they answer
+ // in JSON so a refusal reaches the console as a 422.
+ $router->group(['middleware' => [Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class]], function ($router) {
+ $router->fleetbaseRoutes('inspection-forms', function ($router, $controller) {
+ $router->post('{id}/publish', $controller('publish'));
+ $router->post('{id}/archive', $controller('archive'));
+ $router->post('{id}/generate-link', $controller('generateLink'));
+ $router->get('{id}/links', $controller('links'));
+ $router->delete('{id}/links/{linkId}', $controller('revokeLink'));
+ $router->post('{id}/links/{linkId}/send-pin', $controller('sendPin'));
+ });
+ $router->fleetbaseRoutes('inspection-submissions', function ($router, $controller) {
+ $router->match(['get', 'post'], 'export', $controller('export'));
+ $router->post('{id}/submit', $controller('submit'));
+ $router->post('{id}/create-issue', $controller('createIssue'));
+ $router->post('{id}/create-work-order', $controller('createWorkOrder'));
+ $router->post('{id}/resolve', $controller('resolve'));
+ });
+ });
$router->fleetbaseRoutes('work-orders', function ($router, $controller) {
$router->match(['get', 'post'], 'export', $controller('export'));
$router->post('import', $controller('import'));
diff --git a/server/tests/ApiManifestControllerContractsTest.php b/server/tests/ApiManifestControllerContractsTest.php
index f94da2f79..8f31651ac 100644
--- a/server/tests/ApiManifestControllerContractsTest.php
+++ b/server/tests/ApiManifestControllerContractsTest.php
@@ -84,8 +84,8 @@ public function json(mixed $payload = null, int $status = 200): FleetOpsTestResp
/** A stop that records what was done to it instead of touching a database. */
class FleetOpsManifestStopFake extends ManifestStop
{
- public array $marks = [];
- public array $updates = [];
+ public array $marks = [];
+ public array $updates = [];
public ?object $placeForTest = null;
public function markArrived(): ManifestStop
@@ -148,7 +148,7 @@ public function relationLoaded($key): bool
/** A manifest whose stops and appended counts are supplied, not queried. */
class FleetOpsManifestFakeRecord extends Manifest
{
- public $stopsForTest = null;
+ public $stopsForTest;
public function getCompletedStopsAttribute(): int
{
@@ -263,7 +263,7 @@ public function limit(int $limit): self
return $this;
}
- public function get(): \Illuminate\Support\Collection
+ public function get(): Illuminate\Support\Collection
{
return collect($this->results);
}
@@ -451,7 +451,7 @@ function fleetopsManifestStop(string $publicId, string $status, int $sequence, ?
$manifest = new FleetOpsManifestFakeRecord();
$manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true);
- $manifest->stopsForTest = collect([$done, $far, $near, $mid]);
+ $manifest->stopsForTest = collect([$done, $far, $near, $mid]);
FleetOpsManifestControllerProbe::$manifest = $manifest;
FleetOpsManifestControllerProbe::$distances = [
@@ -482,8 +482,8 @@ function fleetopsManifestStop(string $publicId, string $status, int $sequence, ?
$manifest = new FleetOpsManifestFakeRecord();
$manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true);
- $manifest->stopsForTest = collect([$a, $b, $c]);
- FleetOpsManifestControllerProbe::$manifest = $manifest;
+ $manifest->stopsForTest = collect([$a, $b, $c]);
+ FleetOpsManifestControllerProbe::$manifest = $manifest;
FleetOpsManifestControllerProbe::$distances = [
'1.3,103.8->1.3,103.8' => 0.0,
'1.3,103.8->1.31,103.81' => 1500.0,
@@ -506,7 +506,7 @@ function fleetopsManifestStop(string $publicId, string $status, int $sequence, ?
$manifest = new FleetOpsManifestFakeRecord();
$manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true);
- $manifest->stopsForTest = collect([$a, $b, $c]);
+ $manifest->stopsForTest = collect([$a, $b, $c]);
FleetOpsManifestControllerProbe::$manifest = $manifest;
$controller = new FleetOpsManifestControllerProbe();
@@ -585,7 +585,7 @@ public static function callManifestsFor(Driver $driver)
$reached = function (callable $lookup): bool {
try {
$lookup();
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
return true;
}
diff --git a/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php b/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php
index a1b90fd94..8ea039c47 100644
--- a/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php
+++ b/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php
@@ -184,10 +184,10 @@ class FleetOpsGeofenceControllerFake extends GeofenceController
public ?FleetOpsGeofenceQueryFake $nextEventQuery = null;
public array $serializedEvents = [];
- public ?Fleetbase\FleetOps\Models\Driver $historyDriver = null;
+ public ?Driver $historyDriver = null;
public array $historyDriverLookups = [];
- protected function findDriverForHistory(string $driverId): ?Fleetbase\FleetOps\Models\Driver
+ protected function findDriverForHistory(string $driverId): ?Driver
{
$this->historyDriverLookups[] = $driverId;
@@ -364,7 +364,7 @@ function fleetopsGeofenceEvent(array $attributes): GeofenceEventLog
// The endpoint is addressed by public_id and resolves the driver itself; the query
// still filters on the driver's uuid.
- $historyDriver = new Fleetbase\FleetOps\Models\Driver();
+ $historyDriver = new Driver();
$historyDriver->setRawAttributes(['uuid' => 'driver-9', 'public_id' => 'driver_public9'], true);
$controller->historyDriver = $historyDriver;
@@ -384,7 +384,7 @@ function fleetopsGeofenceEvent(array $attributes): GeofenceEventLog
test('driver history answers 404 when the driver does not resolve', function () {
// The endpoint's own not-found branch: findDriverForHistory returns null and the
// request must not proceed to a query keyed on nothing.
- $controller = new FleetOpsGeofenceControllerFake();
+ $controller = new FleetOpsGeofenceControllerFake();
$controller->historyDriver = null;
$response = $controller->driverHistory(Request::create('/geofences/driver/driver_missing/history', 'GET'), 'driver_missing');
diff --git a/server/tests/Feature/Http/Internal/HubControllerTest.php b/server/tests/Feature/Http/Internal/HubControllerTest.php
index 4e8f3f7d4..f87caab86 100644
--- a/server/tests/Feature/Http/Internal/HubControllerTest.php
+++ b/server/tests/Feature/Http/Internal/HubControllerTest.php
@@ -163,6 +163,9 @@ protected function companyUuid(Request $request): ?string
5, // overdue work orders
6, // open maintenance
7, // high priority maintenance
+ 1, // published inspection forms
+ 2, // failed inspections awaiting review
+ 3, // unresolved failed inspections
8, // low stock parts
9, // equipment
], 'company-maintenance');
@@ -175,32 +178,55 @@ protected function companyUuid(Request $request): ?string
'value' => 2,
'tone' => 'rose',
])
+ // Failed inspections take the third tile: a failed DVIR is a truck that
+ // should not be on the road, which outranks the parts shelf.
->and($payload['kpis'][2])->toMatchArray([
+ 'key' => 'failed_inspections',
+ 'value' => 2,
+ 'tone' => 'rose',
+ 'route' => 'maintenance.inspection-submissions',
+ ])
+ ->and($payload['kpis'][3])->toMatchArray([
'key' => 'open_work_orders',
'value' => 4,
'tone' => 'amber',
])
->and(array_column($payload['actions'], 'key'))->toBe([
+ 'failed_inspections',
'overdue_schedules',
'overdue_work_orders',
'high_priority_maintenance',
'upcoming_service',
- 'low_stock_parts',
+ ])
+ ->and($payload['actions'][0])->toMatchArray([
+ 'description' => '2 failed inspections need issue or work order follow-up.',
+ 'tone' => 'warning',
+ 'route' => 'maintenance.inspection-submissions',
])
->and(array_column($payload['sections'], 'key'))->toBe([
+ 'inspections',
'planning',
'records',
])
->and($payload['sections'][0]['links'][0])->toMatchArray([
+ 'label' => 'Inspection Forms',
+ 'count' => 1,
+ ])
+ ->and($payload['sections'][0]['links'][1])->toMatchArray([
+ 'label' => 'Inspections',
+ 'count' => 3,
+ ])
+ ->and($payload['sections'][1]['links'][0])->toMatchArray([
'label' => 'Schedules',
'count' => 5,
])
->and(array_column($payload['docs'], 'label'))->toBe([
'Schedules',
+ 'Inspections',
'Work Orders',
'Equipment',
'Parts',
])
- ->and($controller->countCalls)->toHaveCount(8)
+ ->and($controller->countCalls)->toHaveCount(11)
->and(array_unique($controller->countCalls))->toBe(['company-maintenance']);
});
diff --git a/server/tests/HubAndGettingStartedContractsTest.php b/server/tests/HubAndGettingStartedContractsTest.php
index 75c741861..e9d75fb5c 100644
--- a/server/tests/HubAndGettingStartedContractsTest.php
+++ b/server/tests/HubAndGettingStartedContractsTest.php
@@ -90,15 +90,38 @@ public function callHelper(string $method): array
$actions = $controller->callHelper('maintenanceActions', 1, 2, 0, 3, 4, 5, 0);
+ // No published inspection form is the first thing a new maintenance setup is
+ // told about: without one, drivers have nothing to file a DVIR against.
expect(array_column($actions, 'key'))->toBe([
+ 'create_inspection_forms',
'overdue_schedules',
'overdue_work_orders',
'high_priority_maintenance',
'upcoming_service',
- 'no_open_work_orders',
])
- ->and($actions[0]['description'])->toContain('1 recurring service schedule is overdue')
- ->and($actions[1]['description'])->toContain('3 work orders are past due');
+ ->and($actions[0])->toMatchArray(['tone' => 'info', 'route' => 'maintenance.inspection-forms'])
+ ->and($actions[1]['description'])->toContain('1 recurring service schedule is overdue')
+ ->and($actions[2]['description'])->toContain('3 work orders are past due');
+});
+
+test('hub controller maintenance actions surface failed and unresolved inspections', function () {
+ $controller = new FleetOpsHubControllerProbe();
+
+ // A failed inspection awaiting review outranks everything else, and while one
+ // is waiting the unresolved count is not repeated as a second action.
+ $failed = $controller->callHelper('maintenanceActions', 0, 0, 1, 0, 0, 0, 1, 1, 4, 2);
+ expect(array_column($failed, 'key'))->toBe(['failed_inspections'])
+ ->and($failed[0]['description'])->toBe('1 failed inspection needs issue or work order follow-up.')
+ ->and((array) $failed[0]['query'])->toBe(['result' => 'failed']);
+
+ // Reviewed but not yet resolved: the follow-up prompt, after the service ones.
+ $unresolved = $controller->callHelper('maintenanceActions', 0, 0, 1, 0, 0, 0, 1, 0, 2, 1);
+ expect(array_column($unresolved, 'key'))->toBe(['unresolved_inspections'])
+ ->and($unresolved[0]['description'])->toBe('2 failed inspections remain unresolved.');
+
+ // Forms published and nothing failed: no inspection action at all.
+ $quiet = $controller->callHelper('maintenanceActions', 0, 0, 1, 0, 0, 0, 1, 0, 0, 3);
+ expect(array_column($quiet, 'key'))->toBe([]);
});
test('hub controller small helpers build dashboard payload fragments', function () {
diff --git a/server/tests/InspectionControllerContractsTest.php b/server/tests/InspectionControllerContractsTest.php
new file mode 100644
index 000000000..771609504
--- /dev/null
+++ b/server/tests/InspectionControllerContractsTest.php
@@ -0,0 +1,1329 @@
+sqliteCreateFunction('ST_PointFromText', $asStoredPoint);
+ $pdo->sqliteCreateFunction('ST_GeomFromText', $asStoredPoint);
+ $connection = new SQLiteConnection($pdo);
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ EloquentModel::setConnectionResolver($resolver);
+ EloquentModel::setEventDispatcher(new Dispatcher());
+ EloquentModel::clearBootedModels();
+
+ $config = new Repository([
+ 'activitylog' => ['enabled' => false, 'default_auth_driver' => null, 'default_log_name' => 'default'],
+ 'api' => ['cache' => ['enabled' => false]],
+ 'filesystems' => ['default' => 'local'],
+ ]);
+ app()->instance('config', $config);
+ app()->instance(Illuminate\Contracts\Config\Repository::class, $config);
+ app()->instance(Spatie\Activitylog\CauserResolver::class, new class extends Spatie\Activitylog\CauserResolver {
+ public function __construct()
+ {
+ }
+
+ public function resolve(EloquentModel|int|string|null $subject = null): ?EloquentModel
+ {
+ return null;
+ }
+ });
+ app()->instance('db', new class($connection) {
+ public function __construct(public SQLiteConnection $connection)
+ {
+ }
+
+ public function connection($name = null): SQLiteConnection
+ {
+ return $this->connection;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return $this->connection->{$method}(...$arguments);
+ }
+ });
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+ app()->instance('db.schema', $connection->getSchemaBuilder());
+ // The link's token and PIN are stored with the `encrypted` cast, which
+ // resolves the container's encrypter. A reversible stand-in is enough to
+ // show a value goes in encrypted and comes back as it was.
+ $encrypter = new class {
+ // Eloquent's `encrypted` cast calls encrypt($value, false) and
+ // decrypt($value, false); the string variants are here for anything
+ // that goes through Crypt::encryptString() instead.
+ public function encrypt($value, $serialize = true)
+ {
+ return 'enc:' . base64_encode($serialize ? serialize($value) : (string) $value);
+ }
+
+ public function decrypt($value, $unserialize = true)
+ {
+ if (!is_string($value) || !str_starts_with($value, 'enc:')) {
+ throw new RuntimeException('Unable to decrypt.');
+ }
+
+ $decoded = base64_decode(substr($value, 4), true);
+
+ return $unserialize ? unserialize($decoded) : $decoded;
+ }
+
+ public function encryptString($value)
+ {
+ return $this->encrypt($value, false);
+ }
+
+ public function decryptString($value)
+ {
+ return $this->decrypt($value, false);
+ }
+ };
+ app()->instance('encrypter', $encrypter);
+ Illuminate\Support\Facades\Crypt::clearResolvedInstance('encrypter');
+ EloquentModel::encryptUsing($encrypter);
+ app()->instance('responsecache', new class {
+ public function __call($method, $arguments)
+ {
+ return null;
+ }
+ });
+ app()->instance('request', Request::create('/'));
+
+ $schema = $connection->getSchemaBuilder();
+ $tables = [
+ 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'],
+ 'inspection_links' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'driver_uuid', 'vehicle_uuid', 'assignee_uuid', 'created_by_uuid', 'token_hash', 'token', 'pin_hash', 'pin', 'pin_attempts', 'pin_sent_via', 'pin_sent_at', 'status', 'single_use', 'expires_at', 'last_viewed_at', 'used_at', 'used_ip', 'used_user_agent', 'meta'],
+ 'inspection_submissions' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'vehicle_uuid', 'driver_uuid', 'submitted_by_uuid', 'issue_uuid', 'work_order_uuid', 'type', 'status', 'result', 'source', 'odometer', 'engine_hours', 'total_items', 'failed_items', 'started_at', 'submitted_at', 'resolved_at', 'location', 'signature', 'attachments', 'meta', 'created_by_uuid', 'updated_by_uuid'],
+ 'inspection_item_results' => ['uuid', '_key', 'company_uuid', 'inspection_submission_uuid', 'issue_uuid', 'work_order_uuid', 'item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos', 'meta', 'created_by_uuid', 'updated_by_uuid'],
+ 'issues' => ['uuid', 'public_id', '_key', 'company_uuid', 'reported_by_uuid', 'assigned_to_uuid', 'vehicle_uuid', 'driver_uuid', 'order_uuid', 'issue_id', 'location', 'category', 'type', 'report', 'title', 'tags', 'priority', 'meta', 'resolved_at', 'status'],
+ 'work_orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'schedule_uuid', 'code', 'subject', 'category', 'status', 'priority', 'target_type', 'target_uuid', 'assignee_type', 'assignee_uuid', 'opened_at', 'due_at', 'closed_at', 'instructions', 'checklist', 'currency', 'estimated_cost', 'approved_budget', 'actual_cost', 'cost_center', 'budget_code', 'meta', 'created_by_uuid', 'updated_by_uuid'],
+ 'vehicles' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'vendor_uuid', 'photo_uuid', 'name', 'make', 'model', 'year', 'trim', 'plate_number', 'vin', 'status', 'currency', 'slug', 'online', 'location'],
+ 'drivers' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'current_job_uuid', 'photo_uuid', 'status', 'online', 'location', 'slug'],
+ 'users' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'email', 'phone', 'avatar_uuid', 'type', 'status'],
+ 'companies' => ['uuid', 'public_id', '_key', 'name', 'owner_uuid', 'options'],
+ 'company_users' => ['uuid', '_key', 'company_uuid', 'user_uuid', 'role_uuid', 'status'],
+ 'settings' => ['key', 'value'],
+ 'files' => ['uuid', 'public_id', '_key', 'company_uuid', 'uploader_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'bucket', 'folder', 'etag', 'meta', 'original_filename', 'type', 'content_type', 'file_size', 'slug', 'caption'],
+ 'vendors' => ['uuid', 'public_id', '_key', 'company_uuid', 'name'],
+ 'orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'driver_assigned_uuid', 'status'],
+ 'positions' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_uuid', 'subject_type', 'coordinates'],
+ 'maintenances' => ['uuid', 'public_id', '_key', 'company_uuid', 'maintainable_type', 'maintainable_uuid', 'status', 'completed_at'],
+ 'maintenance_schedules' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_type', 'subject_uuid', 'status', 'next_due_at'],
+ 'custom_field_values' => ['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'],
+ 'custom_fields' => ['uuid', 'company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order'],
+ 'categories' => ['uuid', 'public_id', '_key', 'company_uuid', 'owner_uuid', 'owner_type', 'parent_uuid', 'icon_file_uuid', 'internal_id', 'name', 'description', 'translations', 'meta', 'tags', 'icon', 'icon_color', 'slug', 'order', 'for', 'core_category'],
+ 'activity_log' => ['uuid', 'company_uuid', 'log_name', 'description', 'subject_type', 'subject_id', 'causer_type', 'causer_id', 'properties', 'event', 'batch_uuid'],
+ ];
+
+ foreach ($tables as $table => $columns) {
+ $schema->create($table, function ($blueprint) use ($columns) {
+ $blueprint->increments('id');
+ foreach ($columns as $column) {
+ $blueprint->string($column)->nullable();
+ }
+ $blueprint->timestamps();
+ $blueprint->timestamp('deleted_at')->nullable();
+ });
+ }
+
+ $connection->table('companies')->insert(['uuid' => 'company-insp', 'public_id' => 'company_insp', 'name' => 'Inspection Co']);
+ $connection->table('companies')->insert(['uuid' => 'company-other', 'public_id' => 'company_other', 'name' => 'Someone Else']);
+ $connection->table('users')->insert(['uuid' => 'user-driver', 'public_id' => 'user_driver', 'company_uuid' => 'company-insp', 'name' => 'Dana Driver', 'email' => 'dana@example.com', 'phone' => '+15550001111', 'type' => 'user']);
+ $connection->table('users')->insert(['uuid' => 'user-admin', 'public_id' => 'user_admin', 'company_uuid' => 'company-insp', 'name' => 'Avery Admin', 'email' => 'avery@example.com', 'type' => 'user']);
+ $connection->table('vehicles')->insert(['uuid' => 'vehicle-1', 'public_id' => 'vehicle_one', 'company_uuid' => 'company-insp', 'name' => 'Truck 7', 'plate_number' => 'TRK-7', 'currency' => 'SGD']);
+ $connection->table('vehicles')->insert(['uuid' => 'vehicle-2', 'public_id' => 'vehicle_two', 'company_uuid' => 'company-insp', 'name' => 'Van 2', 'plate_number' => 'VAN-2']);
+ $connection->table('vehicles')->insert(['uuid' => 'vehicle-other', 'public_id' => 'vehicle_other', 'company_uuid' => 'company-other', 'name' => 'Not Ours']);
+ $connection->table('drivers')->insert(['uuid' => 'driver-1', 'public_id' => 'driver_one', 'company_uuid' => 'company-insp', 'user_uuid' => 'user-driver', 'vehicle_uuid' => 'vehicle-1', 'status' => 'active']);
+ $connection->table('drivers')->insert(['uuid' => 'driver-other', 'public_id' => 'driver_other', 'company_uuid' => 'company-other', 'user_uuid' => null, 'status' => 'active']);
+
+ session(['company' => 'company-insp', 'user' => 'user-admin']);
+
+ return $connection;
+}
+
+function fleetOpsInspectionControllerForm(array $attributes = []): InspectionForm
+{
+ return InspectionForm::create(array_merge([
+ 'company_uuid' => 'company-insp',
+ 'name' => 'Pre-trip DVIR',
+ 'type' => 'dvir',
+ 'status' => 'published',
+ 'published_at' => '2026-09-01 08:00:00',
+ 'items' => [
+ ['key' => 'brakes', 'label' => 'Brakes', 'category' => 'Safety', 'severity' => 'critical'],
+ ['key' => 'lights', 'label' => 'Lights', 'category' => 'Safety', 'severity' => 'medium'],
+ ],
+ 'settings' => ['create_issue_on_failure' => true, 'create_work_order_on_failure' => true],
+ ], $attributes));
+}
+
+function fleetOpsInspectionControllerLink(InspectionForm $form, string $token, array $attributes = []): InspectionLink
+{
+ return InspectionLink::create(array_merge([
+ 'company_uuid' => 'company-insp',
+ 'inspection_form_uuid' => $form->uuid,
+ 'driver_uuid' => 'driver-1',
+ 'vehicle_uuid' => 'vehicle-1',
+ 'created_by_uuid' => 'user-admin',
+ 'token_hash' => InspectionLink::hashToken($token),
+ 'status' => 'active',
+ 'single_use' => true,
+ ], $attributes));
+}
+
+function fleetOpsInspectionControllerBody(array $overrides = []): array
+{
+ return array_merge([
+ 'odometer' => 120400,
+ 'item_results' => [
+ ['item_key' => 'brakes', 'label' => 'Brakes', 'passed' => false, 'severity' => 'critical', 'comments' => 'Soft pedal', 'photos' => ['iVBORw0KGgoAAAANSUhEUg==']],
+ ['item_key' => 'lights', 'label' => 'Lights', 'passed' => true],
+ ],
+ ], $overrides);
+}
+
+/** Runs a public-link call and hands back the refusal it aborted with, if any. */
+function fleetOpsInspectionControllerRefusal(callable $call): ?JsonResponse
+{
+ try {
+ $call();
+ } catch (HttpResponseException $exception) {
+ return $exception->getResponse();
+ }
+
+ return null;
+}
+
+/*
+ * A stored file's caption is humanized from its name by a core-api macro that
+ * is not registered in this harness. The same stand-in the field tests use.
+ */
+if (!Illuminate\Support\Str::hasMacro('humanize')) {
+ Illuminate\Support\Str::macro('humanize', fn ($value, $uppercase = true) => str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value)));
+}
+
+if (!function_exists('event')) {
+ /**
+ * The platform's event(), fired by core's File observer when a file is
+ * recorded. Nothing listens in this harness, so it dispatches nothing.
+ */
+ function event(...$arguments)
+ {
+ return null;
+ }
+}
+
+if (!function_exists('report')) {
+ /**
+ * The platform's report(), which this package's test bootstrap does not
+ * define: hands the exception to whichever handler is bound.
+ */
+ function report($exception)
+ {
+ app(Illuminate\Contracts\Debug\ExceptionHandler::class)->report($exception);
+ }
+}
+
+/** A route that names only its URI, which is all a resource reads to tell internal from public. */
+class FleetOpsInspectionControllerRouteFixture
+{
+ public function __construct(private string $uri)
+ {
+ }
+
+ public function uri(): string
+ {
+ return $this->uri;
+ }
+}
+
+/** Binds an internal request, so resources include what only the console is shown. */
+function fleetOpsInspectionControllerInternalRequest(string $method = 'GET', array $input = []): Request
+{
+ $uri = 'int/v1/inspection-forms';
+ $request = Request::create('/' . $uri, $method, $input);
+ $request->setRouteResolver(fn () => new FleetOpsInspectionControllerRouteFixture($uri));
+ app()->instance('request', $request);
+
+ return $request;
+}
+
+/**
+ * Swap in a container that answers environment(), which Utils::consoleUrl()
+ * asks when a link's address is built. Copied from the driver auth tests.
+ */
+function fleetOpsInspectionControllerContainer(): void
+{
+ $current = Illuminate\Container\Container::getInstance();
+ if (method_exists($current, 'hasDebugModeEnabled')) {
+ return;
+ }
+
+ $replacement = new class extends Illuminate\Container\Container {
+ public function environment(...$environments)
+ {
+ if (empty($environments)) {
+ return 'testing';
+ }
+
+ $checks = is_array($environments[0]) ? $environments[0] : $environments;
+
+ return in_array('testing', $checks, true);
+ }
+
+ public function hasDebugModeEnabled()
+ {
+ return true;
+ }
+ };
+
+ foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'extenders', 'tags', 'contextual', 'scopedInstances', 'reboundCallbacks', 'globalBeforeResolvingCallbacks', 'globalResolvingCallbacks', 'globalAfterResolvingCallbacks', 'beforeResolvingCallbacks', 'resolvingCallbacks', 'afterResolvingCallbacks'] as $property) {
+ if (!property_exists(Illuminate\Container\Container::class, $property)) {
+ continue;
+ }
+ $reflection = new ReflectionProperty(Illuminate\Container\Container::class, $property);
+ $reflection->setAccessible(true);
+ if ($reflection->isInitialized($current)) {
+ $reflection->setValue($replacement, $reflection->getValue($current));
+ }
+ }
+
+ Illuminate\Container\Container::setInstance($replacement);
+ Illuminate\Support\Facades\Facade::setFacadeApplication($replacement);
+}
+
+/**
+ * Lets a link's PIN actually be sent inside the harness: the console's host, a
+ * mailer and an SMS service that record what they are handed, and a handler
+ * for report(). Each can be told to fail, through the object returned.
+ */
+function fleetOpsInspectionControllerDelivery(): object
+{
+ fleetOpsInspectionControllerContainer();
+ config(['fleetbase.console.host' => 'console.test', 'fleetbase.console.secure' => true]);
+
+ $fakes = new class {
+ public array $mail = [];
+ public array $sms = [];
+ public array $reported = [];
+ public mixed $to = null;
+ public ?Throwable $mailFails = null;
+ public ?Throwable $smsFails = null;
+ public ?array $smsAnswer = null;
+ };
+
+ Illuminate\Support\Facades\Mail::swap(new class($fakes) {
+ public function __construct(private object $fakes)
+ {
+ }
+
+ public function to($users)
+ {
+ $this->fakes->to = $users;
+
+ return $this;
+ }
+
+ public function send($mailable)
+ {
+ if ($this->fakes->mailFails) {
+ throw $this->fakes->mailFails;
+ }
+
+ $this->fakes->mail[] = $mailable;
+
+ return null;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return $this;
+ }
+ });
+
+ app()->instance(Fleetbase\Services\SmsService::class, new class($fakes) extends Fleetbase\Services\SmsService {
+ public function __construct(private object $fakes)
+ {
+ }
+
+ public function send(string $to, string $text, array $options = [], ?string $provider = null): array
+ {
+ if ($this->fakes->smsFails) {
+ throw $this->fakes->smsFails;
+ }
+
+ $this->fakes->sms[] = ['to' => $to, 'text' => $text, 'options' => $options];
+
+ return $this->fakes->smsAnswer ?? ['success' => true, 'provider' => 'twilio'];
+ }
+ });
+
+ app()->instance(Illuminate\Contracts\Debug\ExceptionHandler::class, new class($fakes) {
+ public function __construct(private object $fakes)
+ {
+ }
+
+ public function report(Throwable $e): void
+ {
+ $this->fakes->reported[] = $e;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return null;
+ }
+ });
+
+ return $fakes;
+}
+
+/** A real disk under a temporary directory, public so a stored file has a URL. */
+function fleetOpsInspectionControllerDisk(): string
+{
+ $root = sys_get_temp_dir() . '/fleetops-inspection-uploads-' . bin2hex(random_bytes(4));
+ config([
+ 'filesystems.default' => 'public',
+ 'filesystems.disks.public' => ['driver' => 'local', 'root' => $root, 'url' => 'https://files.test/storage', 'visibility' => 'public'],
+ ]);
+
+ $manager = new Illuminate\Filesystem\FilesystemManager(app());
+ app()->instance('filesystem', $manager);
+ app()->instance(Illuminate\Contracts\Filesystem\Factory::class, $manager);
+ Illuminate\Support\Facades\Storage::clearResolvedInstances();
+ Illuminate\Support\Facades\Storage::swap($manager);
+
+ return $root;
+}
+
+/** A one-pixel PNG, under whatever name the device gave it. */
+function fleetOpsInspectionControllerPhoto(string $clientName = 'photo.php'): Illuminate\Http\UploadedFile
+{
+ $path = tempnam(sys_get_temp_dir(), 'insp');
+ file_put_contents($path, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='));
+
+ return new Illuminate\Http\UploadedFile($path, $clientName, 'image/png', null, true);
+}
+
+afterEach(function () {
+ Carbon::setTestNow();
+});
+
+test('public inspection link refuses a missing, unknown, unpublished, invalid or spent token', function () {
+ fleetOpsInspectionControllerDatabase();
+ $controller = new PublicInspectionController();
+ $form = fleetOpsInspectionControllerForm();
+ fleetOpsInspectionControllerLink($form, 'good-token');
+ fleetOpsInspectionControllerLink($form, 'spent-token', ['used_at' => '2026-09-01 09:00:00']);
+
+ $refusal = fn (array $query, ?string $id = null) => fleetOpsInspectionControllerRefusal(
+ fn () => $controller->show(Request::create('/public/inspections/forms/x', 'GET', $query), $id ?? $form->public_id)
+ );
+
+ expect($refusal([])->getStatusCode())->toBe(403)
+ ->and($refusal([])->getData(true))->toBe(['error' => 'Inspection token is required.'])
+ ->and($refusal(['token' => 'nope'])->getData(true))->toBe(['error' => 'Inspection link is invalid.'])
+ ->and($refusal(['token' => 'spent-token'])->getData(true))->toBe(['error' => 'Inspection link is expired, revoked, or already used.']);
+
+ $draft = fleetOpsInspectionControllerForm(['status' => 'draft', 'published_at' => null]);
+ expect($refusal(['token' => 'good-token'], $draft->public_id)->getData(true))->toBe(['error' => 'This inspection form is not available.']);
+
+ expect(fn () => $controller->show(Request::create('/public/inspections/forms/x', 'GET', ['token' => 'good-token']), 'inspection_form_missing'))
+ ->toThrow(ModelNotFoundException::class);
+});
+
+test('public inspection link shows the form with the identity it was minted for', function () {
+ fleetOpsInspectionControllerDatabase();
+ Carbon::setTestNow('2026-09-09 08:00:00');
+ $controller = new PublicInspectionController();
+ $form = fleetOpsInspectionControllerForm();
+ $link = fleetOpsInspectionControllerLink($form, 'good-token', ['expires_at' => '2026-09-10 08:00:00']);
+
+ $payload = $controller->show(Request::create('/public/inspections/forms/x', 'GET', ['token' => 'good-token']), $form->uuid)->getData(true);
+
+ expect($payload['form']['id'])->toBe($form->public_id)
+ ->and($payload['form']['items'])->toHaveCount(2)
+ ->and($payload['form']['is_published'])->toBeTrue()
+ ->and($payload['identity']['driver'])->toBe(['id' => 'driver_one', 'name' => 'Dana Driver'])
+ ->and($payload['identity']['vehicle'])->toBe(['id' => 'vehicle_one', 'name' => 'Truck 7', 'plate_number' => 'TRK-7'])
+ ->and($payload['identity']['expires_at'])->toStartWith('2026-09-10')
+ ->and($link->fresh()->last_viewed_at->toDateTimeString())->toBe('2026-09-09 08:00:00');
+
+ // A link minted for nobody in particular carries no identity.
+ fleetOpsInspectionControllerLink($form, 'anon-token', ['driver_uuid' => null, 'vehicle_uuid' => null]);
+ $anonymous = $controller->show(Request::create('/public/inspections/forms/x', 'GET', ['token' => 'anon-token']), $form->public_id)->getData(true);
+ expect($anonymous['identity'])->toBe(['assignee' => null, 'driver' => null, 'vehicle' => null, 'expires_at' => null]);
+});
+
+test('public inspection link files a submission and spends the link', function () {
+ fleetOpsInspectionControllerDatabase();
+ Carbon::setTestNow('2026-09-09 08:30:00');
+ $controller = new PublicInspectionController();
+ $form = fleetOpsInspectionControllerForm();
+ InspectionFormSync::convertLegacyItems($form);
+ $link = fleetOpsInspectionControllerLink($form, 'good-token');
+ $server = ['REMOTE_ADDR' => '203.0.113.9', 'HTTP_USER_AGENT' => 'Safari'];
+
+ // A link answers the form's fields. The first cut's flat checklist stores
+ // photo URLs as given, so it is refused, and refusing it spends nothing.
+ $flat = fleetOpsInspectionControllerRefusal(fn () => $controller->submit(
+ Request::create('/public/inspections/forms/x/submit', 'POST', fleetOpsInspectionControllerBody(['token' => 'good-token']), [], [], $server),
+ $form->public_id
+ ));
+ expect($flat->getStatusCode())->toBe(422)
+ ->and($flat->getData(true)['error'])->toContain('form fields')
+ ->and($link->fresh()->used_at)->toBeNull();
+
+ $request = Request::create('/public/inspections/forms/x/submit', 'POST', [
+ 'token' => 'good-token',
+ 'odometer' => 120400,
+ 'signature' => ['name' => 'Dana Driver'],
+ 'custom_field_values' => [
+ ['custom_field' => 'brakes', 'value_type' => 'object', 'value' => ['passed' => false, 'severity' => 'critical', 'comments' => 'Soft pedal', 'photos' => []]],
+ ['custom_field' => 'lights', 'value_type' => 'object', 'value' => ['passed' => true]],
+ ],
+ ], [], [], $server);
+ $payload = $controller->submit($request, $form->public_id)->getData(true);
+
+ expect($payload['message'])->toBe('Inspection submitted.')
+ ->and($payload['submission']['source'])->toBe('public_link')
+ ->and($payload['submission']['status'])->toBe('submitted')
+ ->and($payload['submission']['result'])->toBe('failed')
+ ->and($payload['submission']['failed_items'])->toBe(1)
+ ->and($payload['submission']['meta']['inspection_link_id'])->toBe($link->public_id)
+ // Who typed their name, beside the account the link credits, and
+ // whether a PIN stood between the link and the form.
+ ->and($payload['submission']['meta']['completed_by_name'])->toBe('Dana Driver')
+ ->and($payload['submission']['meta']['pin_verified'])->toBeFalse()
+ ->and($payload['submission']['item_results'])->toHaveCount(2)
+ ->and($payload['submission']['issue']['id'])->toStartWith('issue_')
+ ->and($payload['submission']['work_order']['id'])->toStartWith('work_order_')
+ ->and($payload['submission']['driver']['id'])->toBe('driver_one')
+ ->and($payload['submission']['vehicle']['id'])->toBe('vehicle_one')
+ ->and($payload['submission']['form']['id'])->toBe($form->public_id);
+
+ // Nobody is assigned, so the submission is credited to the driver's account.
+ $submission = InspectionSubmission::query()->first();
+ expect($submission->submitted_by_uuid)->toBe('user-driver')
+ ->and($submission->driver_uuid)->toBe('driver-1')
+ ->and($submission->vehicle_uuid)->toBe('vehicle-1');
+
+ $spent = $link->fresh();
+ expect($spent->used_at->toDateTimeString())->toBe('2026-09-09 08:30:00')
+ ->and($spent->used_ip)->toBe('203.0.113.9')
+ ->and($spent->used_user_agent)->toBe('Safari')
+ ->and($spent->isUsable())->toBeFalse();
+
+ // Spent is spent: the same token cannot file twice.
+ expect(fleetOpsInspectionControllerRefusal(fn () => $controller->submit($request, $form->public_id))->getStatusCode())->toBe(403);
+});
+
+test('internal inspection form controller publishes archives and mints links', function () {
+ fleetOpsInspectionControllerDatabase();
+ Carbon::setTestNow('2026-09-09 09:00:00');
+ $controller = new InspectionFormController();
+ $form = fleetOpsInspectionControllerForm(['status' => 'draft', 'published_at' => null]);
+
+ // A draft cannot be handed out.
+ $refused = $controller->generateLink(Request::create('/', 'POST'), $form->public_id);
+ expect($refused->getStatusCode())->toBe(422)
+ ->and($refused->getData(true)['error'])->toContain('must be published');
+
+ $published = $controller->publish($form->public_id)->getData(true);
+ expect($published['status'])->toBe('ok')
+ ->and($published['data']['status'])->toBe('published')
+ ->and($published['data']['is_published'])->toBeTrue();
+
+ $minted = $controller->generateLink(Request::create('/', 'POST', ['driver' => 'driver_one', 'vehicle' => 'vehicle-1', 'expires_at' => '2026-09-10 09:00:00', 'single_use' => false]), $form->uuid)->getData(true);
+ $link = InspectionLink::query()->first();
+
+ expect($minted['status'])->toBe('ok')
+ ->and($minted['link']['id'])->toBe($link->public_id)
+ ->and($minted['link']['path'])->toBe('/~/inspection?id=' . urlencode($form->public_id) . '&token=' . urlencode($minted['link']['token']))
+ ->and(strlen($minted['link']['token']))->toBe(64)
+ ->and($link->token_hash)->toBe(InspectionLink::hashToken($minted['link']['token']))
+ ->and($link->driver_uuid)->toBe('driver-1')
+ ->and($link->vehicle_uuid)->toBe('vehicle-1')
+ ->and($link->created_by_uuid)->toBe('user-admin')
+ ->and($link->single_use)->toBeFalse()
+ ->and($minted['link']['driver'])->toBe(['id' => 'driver_one', 'name' => 'Dana Driver'])
+ ->and($minted['link']['vehicle'])->toBe(['id' => 'vehicle_one', 'name' => 'Truck 7'])
+ ->and($minted['link']['expires_at'])->toStartWith('2026-09-10');
+
+ // Nobody named: a link anyone may use, single use by default.
+ $open = $controller->generateLink(Request::create('/', 'POST'), $form->public_id)->getData(true);
+ expect($open['link']['driver'])->toBeNull()
+ ->and($open['link']['vehicle'])->toBeNull()
+ ->and((bool) InspectionLink::query()->where('public_id', $open['link']['id'])->value('single_use'))->toBeTrue();
+
+ // A driver or vehicle from another company does not exist here.
+ expect(fn () => $controller->generateLink(Request::create('/', 'POST', ['driver' => 'driver_other']), $form->public_id))->toThrow(ModelNotFoundException::class)
+ ->and(fn () => $controller->generateLink(Request::create('/', 'POST', ['vehicle' => 'vehicle_other']), $form->public_id))->toThrow(ModelNotFoundException::class);
+
+ $archived = $controller->archive($form->public_id)->getData(true);
+ expect($archived['message'])->toBe('Inspection form archived.')
+ ->and($archived['data']['status'])->toBe('archived');
+
+ session(['company' => 'company-other']);
+ expect(fn () => $controller->publish($form->public_id))->toThrow(ModelNotFoundException::class);
+});
+
+test('internal inspection submission controller syncs item results from the console form', function () {
+ fleetOpsInspectionControllerDatabase();
+ $controller = new InspectionSubmissionController();
+ $form = fleetOpsInspectionControllerForm();
+ $submission = InspectionSubmission::create(['company_uuid' => 'company-insp', 'inspection_form_uuid' => $form->uuid, 'vehicle_uuid' => 'vehicle-1', 'driver_uuid' => 'driver-1', 'type' => 'dvir', 'status' => 'draft']);
+
+ // Nothing sent: nothing touched.
+ $controller->onAfterCreate(Request::create('/', 'POST'), $submission, []);
+ expect(InspectionItemResult::query()->count())->toBe(0);
+
+ // The console nests the payload under the resource name.
+ $controller->onAfterCreate(Request::create('/', 'POST', ['inspection_submission' => ['item_results' => [
+ ['item_key' => 'brakes', 'label' => 'Brakes', 'passed' => false, 'severity' => 'critical'],
+ ['title' => 'Untitled item', 'status' => 'failed'],
+ ['label' => 'Horn'],
+ ]]]), $submission, []);
+
+ $results = $submission->itemResults()->orderBy('id')->get();
+ expect($results)->toHaveCount(3)
+ ->and($results[0]->status)->toBe('failed')
+ ->and($results[1]->label)->toBe('Untitled item')
+ ->and($results[1]->passed)->toBeFalse()
+ // No status and no flag: passed, as an unchecked box is.
+ ->and($results[2]->status)->toBe('passed')
+ ->and($results[2]->passed)->toBeTrue()
+ ->and($submission->fresh()->failed_items)->toBe(2)
+ ->and($submission->relationLoaded('itemResults'))->toBeTrue();
+
+ // An update matches by uuid, then item key, then label — and drops what
+ // the console no longer lists.
+ $controller->onAfterUpdate(Request::create('/', 'PUT', ['item_results' => [
+ ['uuid' => $results[0]->uuid, 'item_key' => 'brakes', 'label' => 'Brakes', 'passed' => true],
+ ['label' => 'Horn', 'passed' => false, 'status' => 'failed', 'meta' => ['note' => 'weak']],
+ ]]), $submission->fresh(), []);
+
+ $synced = $submission->fresh();
+ expect($synced->itemResults()->count())->toBe(2)
+ ->and((bool) $synced->itemResults()->where('uuid', $results[0]->uuid)->value('passed'))->toBeTrue()
+ ->and($synced->itemResults()->where('label', 'Untitled item')->exists())->toBeFalse()
+ ->and($synced->itemResults()->where('label', 'Horn')->first()->meta['note'])->toBe('weak')
+ ->and($synced->failed_items)->toBe(1);
+
+ $builder = InspectionSubmission::query();
+ $controller->onFindRecord($builder, Request::create('/'));
+ expect(array_keys($builder->getEagerLoads()))->toContain('form', 'vehicle', 'driver', 'submittedBy', 'issue', 'workOrder', 'itemResults');
+});
+
+test('internal inspection submission controller submits, raises follow-up and resolves', function () {
+ fleetOpsInspectionControllerDatabase();
+ Carbon::setTestNow('2026-09-09 10:00:00');
+ $controller = new InspectionSubmissionController();
+ $form = fleetOpsInspectionControllerForm();
+
+ $clean = InspectionSubmission::create(['company_uuid' => 'company-insp', 'inspection_form_uuid' => $form->uuid, 'vehicle_uuid' => 'vehicle-1', 'driver_uuid' => 'driver-1', 'type' => 'dvir', 'status' => 'draft']);
+ InspectionItemResult::create(['company_uuid' => 'company-insp', 'inspection_submission_uuid' => $clean->uuid, 'label' => 'Brakes', 'passed' => true]);
+
+ $submitted = $controller->submit($clean->public_id)->getData(true);
+ expect($submitted['message'])->toBe('Inspection submitted.')
+ ->and($submitted['data']['status'])->toBe('submitted')
+ ->and($submitted['data']['result'])->toBe('passed');
+
+ // Nothing failed, nothing to raise.
+ $noIssue = $controller->createIssue($clean->uuid)->getData(true);
+ expect($noIssue['message'])->toBe('No failed inspection items found.')
+ ->and($noIssue['issue'])->toBeNull();
+ $noOrder = $controller->createWorkOrder($clean->uuid)->getData(true);
+ expect($noOrder['message'])->toBe('No failed inspection items found.')
+ ->and($noOrder['work_order'])->toBeNull();
+
+ $failed = InspectionSubmission::create(['company_uuid' => 'company-insp', 'inspection_form_uuid' => $form->uuid, 'vehicle_uuid' => 'vehicle-1', 'driver_uuid' => 'driver-1', 'submitted_by_uuid' => 'user-driver', 'type' => 'dvir', 'status' => 'draft']);
+ InspectionItemResult::create(['company_uuid' => 'company-insp', 'inspection_submission_uuid' => $failed->uuid, 'label' => 'Brakes', 'passed' => false, 'severity' => 'high']);
+
+ $issued = $controller->createIssue($failed->public_id)->getData(true);
+ expect($issued['message'])->toBe('Issue created from failed inspection items.')
+ ->and($issued['issue']['title'])->toBe('Failed inspection: Truck 7')
+ ->and($issued['data']['issue']['uuid'])->toBe($issued['issue']['uuid']);
+
+ $ordered = $controller->createWorkOrder($failed->public_id)->getData(true);
+ expect($ordered['message'])->toBe('Work order created from failed inspection items.')
+ ->and($ordered['work_order']['subject'])->toBe('Inspection repair: Truck 7')
+ ->and($ordered['work_order']['meta']['issue_uuid'])->toBe($issued['issue']['uuid'])
+ ->and(Issue::query()->count())->toBe(1)
+ ->and(WorkOrder::query()->count())->toBe(1);
+
+ $resolved = $controller->resolve($failed->public_id)->getData(true);
+ expect($resolved['message'])->toBe('Inspection resolved.')
+ ->and($resolved['data']['status'])->toBe('resolved')
+ ->and($failed->fresh()->resolved_at->toDateTimeString())->toBe('2026-09-09 10:00:00');
+
+ expect(fn () => $controller->submit('inspection_submission_missing'))->toThrow(ModelNotFoundException::class);
+});
+
+test('driver inspection api lists only the published forms a vehicle can be inspected against', function () {
+ fleetOpsInspectionControllerDatabase();
+ $controller = new InspectionController();
+
+ $wide = fleetOpsInspectionControllerForm(['name' => 'Fleet-wide pre-trip', 'type' => 'pre_trip', 'published_at' => '2026-09-01 08:00:00']);
+ $truck = fleetOpsInspectionControllerForm(['name' => 'Truck 7 lift gate', 'subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-1', 'published_at' => '2026-09-03 08:00:00']);
+ fleetOpsInspectionControllerForm(['name' => 'Van 2 only', 'subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-2', 'published_at' => '2026-09-02 08:00:00']);
+ fleetOpsInspectionControllerForm(['name' => 'Still a draft', 'status' => 'draft', 'published_at' => null]);
+ fleetOpsInspectionControllerForm(['name' => 'Says published, never was', 'status' => 'published', 'published_at' => null]);
+ fleetOpsInspectionControllerForm(['name' => 'Retired', 'status' => 'archived']);
+ fleetOpsInspectionControllerForm(['name' => 'Another company', 'company_uuid' => 'company-other']);
+
+ $all = $controller->queryForms(Request::create('/v1/inspection-forms', 'GET'))->resolve();
+ expect(array_column($all, 'name'))->toBe(['Truck 7 lift gate', 'Van 2 only', 'Fleet-wide pre-trip'])
+ ->and($all[0]['id'])->toBe($truck->public_id)
+ ->and($all[0])->not->toHaveKey('uuid');
+
+ $forTruck = $controller->queryForms(Request::create('/v1/inspection-forms', 'GET', ['vehicle' => 'vehicle_one']))->resolve();
+ expect(array_column($forTruck, 'name'))->toBe(['Truck 7 lift gate', 'Fleet-wide pre-trip']);
+
+ $preTrip = $controller->queryForms(Request::create('/v1/inspection-forms', 'GET', ['type' => 'pre_trip,post_trip', 'limit' => 1]))->resolve();
+ expect(array_column($preTrip, 'id'))->toBe([$wide->public_id]);
+
+ $capped = $controller->queryForms(Request::create('/v1/inspection-forms', 'GET', ['limit' => 2]))->resolve();
+ expect($capped)->toHaveCount(2);
+
+ expect($controller->queryForms(Request::create('/v1/inspection-forms', 'GET', ['vehicle' => 'vehicle_other']))->getStatusCode())->toBe(404);
+});
+
+test('driver inspection api shows a published form with its items and settings', function () {
+ fleetOpsInspectionControllerDatabase();
+ $controller = new InspectionController();
+
+ $form = fleetOpsInspectionControllerForm(['subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-1', 'meta' => ['sla' => 'am']]);
+ $draft = fleetOpsInspectionControllerForm(['status' => 'draft', 'published_at' => null]);
+ $other = fleetOpsInspectionControllerForm(['company_uuid' => 'company-other']);
+
+ $shown = $controller->findForm($form->public_id)->resolve();
+ expect($shown['id'])->toBe($form->public_id)
+ ->and($shown['name'])->toBe('Pre-trip DVIR')
+ ->and($shown['type'])->toBe('dvir')
+ ->and($shown['items'])->toHaveCount(2)
+ ->and($shown['item_count'])->toBe(2)
+ ->and($shown['settings'])->toBe(['create_issue_on_failure' => true, 'create_work_order_on_failure' => true])
+ ->and($shown['meta'])->toBe(['sla' => 'am'])
+ ->and($shown['is_published'])->toBeTrue()
+ ->and($shown['subject_name'])->toBe('Truck 7')
+ ->and($shown['subject']['id'])->toBe('vehicle_one')
+ ->and($shown['subject']['type'])->toBe('maintenance-subject-vehicle')
+ ->and($shown['subject']['subject_type'])->toBe('maintenance-subject-vehicle');
+
+ // By uuid as well as public id.
+ expect($controller->findForm($form->uuid)->resolve()['id'])->toBe($form->public_id);
+
+ // A form bound to nothing has no subject to describe.
+ $wide = fleetOpsInspectionControllerForm();
+ expect($controller->findForm($wide->public_id)->resolve()['subject'])->toBeNull();
+
+ // Draft, other company, or nothing at all: all the same 404.
+ expect($controller->findForm($draft->public_id)->getStatusCode())->toBe(404)
+ ->and($controller->findForm($other->public_id)->getStatusCode())->toBe(404)
+ ->and($controller->findForm('inspection_form_missing')->getStatusCode())->toBe(404)
+ ->and($controller->findForm('inspection_form_missing')->getData(true))->toBe(['error' => 'Inspection form resource not found.']);
+});
+
+test('driver inspection api files a submission and answers with its follow-up', function () {
+ fleetOpsInspectionControllerDatabase();
+ Carbon::setTestNow('2026-09-09 07:30:00');
+ $controller = new InspectionController();
+ $form = fleetOpsInspectionControllerForm();
+
+ $body = fleetOpsInspectionControllerBody([
+ 'inspection_form' => $form->public_id,
+ 'driver' => 'driver_one',
+ 'vehicle' => 'vehicle_two',
+ 'started_at' => '2026-09-09T07:10:00Z',
+ 'location' => ['latitude' => 1.35, 'longitude' => 103.82],
+ ]);
+
+ $filed = $controller->submit(Request::create('/v1/inspections', 'POST', $body))->resolve();
+
+ expect($filed['id'])->toStartWith('inspection_submission_')
+ ->and($filed)->not->toHaveKey('uuid')
+ ->and($filed['source'])->toBe('navigator')
+ ->and($filed['status'])->toBe('submitted')
+ ->and($filed['result'])->toBe('failed')
+ ->and($filed['type'])->toBe('dvir')
+ ->and($filed['odometer'])->toBe(120400)
+ ->and($filed['total_items'])->toBe(2)
+ ->and($filed['failed_items'])->toBe(1)
+ ->and($filed['has_failures'])->toBeTrue()
+ ->and($filed['location'])->toBe(['latitude' => 1.35, 'longitude' => 103.82])
+ ->and($filed['started_at']->toDateTimeString())->toBe('2026-09-09 07:10:00')
+ ->and($filed['submitted_at']->toDateTimeString())->toBe('2026-09-09 07:30:00')
+ ->and($filed['form']->resolve()['id'])->toBe($form->public_id)
+ ->and($filed['vehicle']->resolve()['id'])->toBe('vehicle_two')
+ ->and($filed['driver']->resolve()['id'])->toBe('driver_one')
+ ->and($filed['item_results']->resolve())->toHaveCount(2)
+ ->and($filed['item_results']->resolve()[0])->toMatchArray(['item_key' => 'brakes', 'passed' => false, 'status' => 'failed', 'comments' => 'Soft pedal', 'photos' => ['iVBORw0KGgoAAAANSUhEUg==']])
+ ->and($filed['item_results']->resolve()[0]['submission_id'])->toBe($filed['id'])
+ ->and($filed['issue']->resolve()['id'])->toStartWith('issue_')
+ ->and($filed['work_order']->resolve()['id'])->toStartWith('work_order_');
+
+ $submission = InspectionSubmission::query()->first();
+ expect($submission->submitted_by_uuid)->toBe('user-driver')
+ ->and($submission->driver_uuid)->toBe('driver-1')
+ ->and($submission->vehicle_uuid)->toBe('vehicle-2')
+ ->and($submission->meta)->toBe([]);
+
+ // No vehicle named: the driver's own truck, and no start time: now.
+ $assigned = $controller->submit(Request::create('/v1/inspections', 'POST', fleetOpsInspectionControllerBody([
+ 'inspection_form' => $form->uuid,
+ 'driver' => 'driver-1',
+ 'item_results' => [['label' => 'Horn', 'passed' => true]],
+ ])))->resolve();
+ expect($assigned['vehicle']->resolve()['id'])->toBe('vehicle_one')
+ ->and($assigned['result'])->toBe('passed')
+ ->and($assigned['started_at']->toDateTimeString())->toBe('2026-09-09 07:30:00')
+ ->and($assigned['issue'])->toBeNull()
+ ->and($assigned['work_order'])->toBeNull();
+});
+
+test('driver inspection api answers a replayed submit with the submission it already filed', function () {
+ fleetOpsInspectionControllerDatabase();
+ $controller = new InspectionController();
+ $form = fleetOpsInspectionControllerForm();
+ $body = fleetOpsInspectionControllerBody(['inspection_form' => $form->public_id, 'driver' => 'driver_one']);
+ $replay = fn (string $key) => $controller->submit(Request::create('/v1/inspections', 'POST', $body, [], [], ['HTTP_IDEMPOTENCY_KEY' => $key]))->resolve();
+
+ $first = $replay('queued-7f3a');
+ expect(InspectionSubmission::query()->count())->toBe(1)
+ ->and(InspectionSubmission::query()->first()->meta)->toBe(['idempotency_key' => 'queued-7f3a']);
+
+ // The app lost the response and sent the same queued submit again.
+ $second = $replay('queued-7f3a');
+ expect($second['id'])->toBe($first['id'])
+ ->and($second['item_results']->resolve())->toHaveCount(2)
+ ->and(InspectionSubmission::query()->count())->toBe(1)
+ ->and(Issue::query()->count())->toBe(1);
+
+ // A different key is a different inspection, and the blank header is no key at all.
+ $third = $replay('queued-9c11');
+ expect($third['id'])->not->toBe($first['id'])
+ ->and(InspectionSubmission::query()->count())->toBe(2);
+ $replay(' ');
+ expect(InspectionSubmission::query()->count())->toBe(3);
+});
+
+test('driver inspection api refuses a submit for a driver, form or vehicle it cannot find', function () {
+ fleetOpsInspectionControllerDatabase();
+ $controller = new InspectionController();
+ $form = fleetOpsInspectionControllerForm();
+ $draft = fleetOpsInspectionControllerForm(['status' => 'draft', 'published_at' => null]);
+ $submit = fn (array $overrides) => $controller->submit(Request::create('/v1/inspections', 'POST', fleetOpsInspectionControllerBody(array_merge(['inspection_form' => $form->public_id, 'driver' => 'driver_one'], $overrides))));
+
+ expect($submit(['driver' => 'driver_other'])->getData(true))->toBe(['error' => 'Driver resource not found.'])
+ ->and($submit(['driver' => 'driver_other'])->getStatusCode())->toBe(404)
+ ->and($submit(['inspection_form' => $draft->public_id])->getData(true))->toBe(['error' => 'Inspection form resource not found.'])
+ ->and($submit(['vehicle' => 'vehicle_other'])->getData(true))->toBe(['error' => 'Vehicle resource not found.'])
+ ->and(InspectionSubmission::query()->count())->toBe(0);
+});
+
+test('driver inspection api lists and shows submissions with their filters', function () {
+ fleetOpsInspectionControllerDatabase();
+ $controller = new InspectionController();
+ $form = fleetOpsInspectionControllerForm(['settings' => []]);
+ $postTrip = fleetOpsInspectionControllerForm(['type' => 'post_trip', 'settings' => []]);
+
+ $file = function (InspectionForm $form, string $vehicle, bool $passed, string $at) use ($controller) {
+ Carbon::setTestNow($at);
+
+ return $controller->submit(Request::create('/v1/inspections', 'POST', [
+ 'inspection_form' => $form->public_id,
+ 'driver' => 'driver_one',
+ 'vehicle' => $vehicle,
+ 'item_results' => [['label' => 'Brakes', 'passed' => $passed]],
+ ]))->resolve()['id'];
+ };
+
+ $oldest = $file($form, 'vehicle_one', true, '2026-09-07 08:00:00');
+ $middle = $file($postTrip, 'vehicle_two', false, '2026-09-08 08:00:00');
+ $newest = $file($form, 'vehicle_one', false, '2026-09-09 08:00:00');
+
+ // Someone else's inspection never shows, whatever the filter.
+ InspectionSubmission::create(['company_uuid' => 'company-other', 'inspection_form_uuid' => $form->uuid, 'driver_uuid' => 'driver-other', 'type' => 'dvir', 'status' => 'submitted', 'submitted_at' => '2026-09-10 08:00:00']);
+
+ $ids = fn (Request $request) => array_column($controller->query($request)->resolve(), 'id');
+
+ expect($ids(Request::create('/v1/inspections', 'GET')))->toBe([$newest, $middle, $oldest])
+ ->and($ids(Request::create('/v1/inspections', 'GET', ['driver' => 'driver_one', 'limit' => 2])))->toBe([$newest, $middle])
+ ->and($ids(Request::create('/v1/inspections', 'GET', ['vehicle' => 'vehicle_two'])))->toBe([$middle])
+ ->and($ids(Request::create('/v1/inspections', 'GET', ['type' => 'post_trip'])))->toBe([$middle])
+ ->and($ids(Request::create('/v1/inspections', 'GET', ['result' => 'passed'])))->toBe([$oldest])
+ ->and($ids(Request::create('/v1/inspections', 'GET', ['status' => 'resolved,needs_review'])))->toBe([])
+ ->and($ids(Request::create('/v1/inspections', 'GET', ['limit' => 0])))->toBe([$newest, $middle, $oldest]);
+
+ expect($controller->query(Request::create('/v1/inspections', 'GET', ['driver' => 'driver_other']))->getStatusCode())->toBe(404)
+ ->and($controller->query(Request::create('/v1/inspections', 'GET', ['vehicle' => 'vehicle_other']))->getStatusCode())->toBe(404);
+
+ $shown = $controller->find($newest)->resolve();
+ expect($shown['id'])->toBe($newest)
+ ->and($shown['item_results']->resolve())->toHaveCount(1)
+ ->and($shown['vehicle']->resolve()['id'])->toBe('vehicle_one')
+ ->and($shown['driver']->resolve()['id'])->toBe('driver_one')
+ ->and($shown['form']->resolve()['id'])->toBe($form->public_id)
+ ->and($shown['issue'])->toBeNull()
+ ->and($shown['work_order'])->toBeNull();
+
+ $byUuid = $controller->find(InspectionSubmission::query()->where('public_id', $oldest)->value('uuid'))->resolve();
+ expect($byUuid['id'])->toBe($oldest);
+
+ $foreign = InspectionSubmission::query()->where('company_uuid', 'company-other')->first();
+ expect($controller->find($foreign->public_id)->getStatusCode())->toBe(404)
+ ->and($controller->find('inspection_submission_missing')->getData(true))->toBe(['error' => 'Inspection resource not found.']);
+
+ $history = fn (string $vehicle, array $query = []) => array_column($controller->forVehicle(Request::create('/v1/vehicles/x/inspections', 'GET', $query), $vehicle)->resolve(), 'id');
+ expect($history('vehicle_one'))->toBe([$newest, $oldest])
+ ->and($history('vehicle-1', ['result' => 'failed']))->toBe([$newest])
+ ->and($history('vehicle_one', ['limit' => 1]))->toBe([$newest])
+ ->and($history('vehicle_two', ['type' => 'dvir']))->toBe([])
+ ->and($controller->forVehicle(Request::create('/v1/vehicles/x/inspections', 'GET'), 'vehicle_other')->getStatusCode())->toBe(404);
+});
+
+test('inspection form resource keeps internal identifiers off the driver api', function () {
+ fleetOpsInspectionControllerDatabase();
+ $form = fleetOpsInspectionControllerForm(['subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-1']);
+
+ $resolved = (new InspectionFormResource($form->fresh()))->resolve();
+
+ expect($resolved['id'])->toBe($form->public_id)
+ ->and(array_keys($resolved))->not->toContain('uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'created_by_uuid', 'updated_by_uuid')
+ ->and($resolved['subject']['type'])->toBe('maintenance-subject-vehicle');
+
+ // A form bound to nothing, with the relation resolved as such: no subject
+ // to transform, and nothing to stamp a type onto.
+ $wide = fleetOpsInspectionControllerForm();
+ $wide->setRelation('subject', null);
+ expect((new InspectionFormResource($wide))->resolve()['subject'])->toBeNull();
+
+ // `whenLoaded` answers a loaded-null relation itself, so the two helpers
+ // never see a missing subject through the resource. Reached directly, as
+ // the other morph resources are, so their guards are exercised.
+ $resource = new InspectionFormResource($wide);
+ $transform = new ReflectionMethod(InspectionFormResource::class, 'transformMorphResource');
+ $stamp = new ReflectionMethod(InspectionFormResource::class, 'setSubjectType');
+ $transform->setAccessible(true);
+ $stamp->setAccessible(true);
+
+ expect($transform->invoke($resource, null))->toBeNull()
+ ->and($stamp->invoke($resource, null))->toBeNull()
+ ->and($stamp->invoke($resource, []))->toBe([]);
+});
+
+test('inspection link pin names its recipient, says why it cannot send, and builds its message', function () {
+ fleetOpsInspectionControllerDatabase();
+ fleetOpsInspectionControllerDelivery();
+ $form = fleetOpsInspectionControllerForm();
+
+ $forDriver = fleetOpsInspectionControllerLink($form, 'driver-token');
+ $forAdmin = fleetOpsInspectionControllerLink($form, 'admin-token', ['assignee_uuid' => 'user-admin', 'driver_uuid' => null]);
+ $forNobody = fleetOpsInspectionControllerLink($form, 'open-token', ['driver_uuid' => null]);
+
+ // The assignee first, else the driver's own account, else nobody.
+ $admin = Fleetbase\FleetOps\Support\InspectionLinkPin::recipientFor($forAdmin->fresh());
+ $driver = Fleetbase\FleetOps\Support\InspectionLinkPin::recipientFor($forDriver->fresh());
+ expect($admin->uuid)->toBe('user-admin')
+ ->and($driver->uuid)->toBe('user-driver')
+ ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::recipientFor($forNobody->fresh()))->toBeNull();
+
+ $nameless = (new Fleetbase\Models\User())->forceFill(['name' => null, 'email' => null, 'phone' => null]);
+ expect(Fleetbase\FleetOps\Support\InspectionLinkPin::unavailableReason(null, 'email'))->toContain('Assign the link to someone')
+ ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::unavailableReason($admin, 'sms'))->toBe('Avery Admin has no phone number to text the PIN to.')
+ ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::unavailableReason($nameless, 'email'))->toBe('This person has no email address to send the PIN to.')
+ ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::unavailableReason($driver, 'sms'))->toBeNull()
+ ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::unavailableReason($driver, 'email'))->toBeNull();
+
+ // Enough of where it went to recognise, not enough to copy.
+ expect(Fleetbase\FleetOps\Support\InspectionLinkPin::maskEmail('dana@example.com'))->toBe('d•••@example.com')
+ ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::maskPhone('+1 (555) 000-1111'))->toBe('•••1111');
+
+ // The text names the organisation, and carries the link when there is one.
+ expect(Fleetbase\FleetOps\Support\InspectionLinkPin::smsText($forDriver, '123456', 'https://console.test/x'))
+ ->toBe('Inspection Co: complete the Pre-trip DVIR inspection at https://console.test/x using PIN 123456. Do not share this PIN.')
+ ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::smsText($forDriver, '123456'))
+ ->toBe('Inspection Co: your PIN for the Pre-trip DVIR inspection is 123456. Do not share this PIN.');
+
+ // A link's address is on the console's host; one minted before tokens were kept has none.
+ $kept = fleetOpsInspectionControllerLink($form, 'kept-token', ['token' => 'kept-token']);
+ expect(Fleetbase\FleetOps\Support\InspectionLinkPin::urlFor($kept->fresh()))->toBe('https://console.test/~/inspection?id=' . urlencode($form->public_id) . '&token=kept-token')
+ ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::urlFor($forDriver->fresh()))->toBeNull();
+});
+
+test('inspection link pin is emailed or texted, and says why when it is not', function () {
+ $connection = fleetOpsInspectionControllerDatabase();
+ Carbon::setTestNow('2026-09-09 09:00:00');
+ $fakes = fleetOpsInspectionControllerDelivery();
+ $form = fleetOpsInspectionControllerForm();
+ $link = fleetOpsInspectionControllerLink($form, 'good-token', ['token' => 'good-token']);
+ $send = fn (string $via) => Fleetbase\FleetOps\Support\InspectionLinkPin::send($link->fresh(), $via);
+
+ // Nothing to send before the link has a PIN.
+ expect($send('email'))->toBe(['sent' => false, 'via' => 'email', 'to' => null, 'error' => 'This link has no PIN to send.']);
+
+ $link->setPin('246810');
+ $link->save();
+ $url = 'https://console.test/~/inspection?id=' . urlencode($form->public_id) . '&token=good-token';
+
+ // By email: the link and the PIN, to the driver's account.
+ expect($send('email'))->toBe(['sent' => true, 'via' => 'email', 'to' => 'd•••@example.com', 'error' => null])
+ ->and($fakes->mail)->toHaveCount(1)
+ ->and($fakes->to->uuid)->toBe('user-driver')
+ ->and($link->fresh()->pin_sent_via)->toBe('email')
+ ->and($link->fresh()->pin_sent_at->toDateTimeString())->toBe('2026-09-09 09:00:00');
+
+ $mail = $fakes->mail[0];
+ expect($mail)->toBeInstanceOf(Fleetbase\FleetOps\Mail\InspectionLinkPinMail::class)
+ ->and($mail->envelope()->subject)->toBe('Complete the Pre-trip DVIR inspection');
+ $content = $mail->content();
+ expect($content->markdown)->toBe('fleetops::mail.inspection-link-pin')
+ ->and($content->with['pin'])->toBe('246810')
+ ->and($content->with['url'])->toBe($url)
+ ->and($content->with['recipient']->uuid)->toBe('user-driver')
+ ->and($content->with['form']->uuid)->toBe($form->uuid)
+ ->and($content->with['vehicle']->uuid)->toBe('vehicle-1')
+ ->and($content->with['sender']->uuid)->toBe('user-admin')
+ ->and($content->with['maxAttempts'])->toBe(InspectionLink::MAX_PIN_ATTEMPTS);
+
+ // By SMS, from the organisation's alphanumeric sender when it has one.
+ $connection->table('companies')->where('uuid', 'company-insp')->update(['options' => json_encode(['alpha_numeric_sender_id_enabled' => true, 'alpha_numeric_sender_id' => 'InspCo'])]);
+ expect($send('sms'))->toBe(['sent' => true, 'via' => 'sms', 'to' => '•••1111', 'error' => null])
+ ->and($fakes->sms[0]['to'])->toBe('+15550001111')
+ ->and($fakes->sms[0]['text'])->toBe('Inspection Co: complete the Pre-trip DVIR inspection at ' . $url . ' using PIN 246810. Do not share this PIN.')
+ ->and($fakes->sms[0]['options'])->toBe(['twilioParams' => ['from' => 'InspCo']])
+ ->and($link->fresh()->pin_sent_via)->toBe('sms');
+
+ // Without the sender switched on, the provider's own number is used.
+ $connection->table('companies')->where('uuid', 'company-insp')->update(['options' => json_encode(['alpha_numeric_sender_id' => 'InspCo'])]);
+ $send('sms');
+ expect($fakes->sms[1]['options'])->toBe([]);
+
+ // A provider that refuses, and a delivery that fails in transit, are
+ // reported back rather than thrown.
+ $fakes->smsAnswer = ['success' => false, 'error' => 'Number is blocked'];
+ expect($send('sms'))->toBe(['sent' => false, 'via' => 'sms', 'to' => null, 'error' => 'The PIN could not be texted: Number is blocked']);
+
+ $fakes->smsAnswer = null;
+ $fakes->smsFails = new RuntimeException('Twilio is down');
+ expect($send('sms'))->toBe(['sent' => false, 'via' => 'sms', 'to' => null, 'error' => 'The PIN could not be sent: Twilio is down'])
+ ->and($fakes->reported)->toHaveCount(1);
+
+ $fakes->mailFails = new RuntimeException('SMTP refused');
+ expect($send('email')['error'])->toBe('The PIN could not be sent: SMTP refused');
+
+ // Someone with no phone cannot be texted.
+ $forAdmin = fleetOpsInspectionControllerLink($form, 'admin-token', ['assignee_uuid' => 'user-admin']);
+ $forAdmin->setPin('112233');
+ $forAdmin->save();
+ expect(Fleetbase\FleetOps\Support\InspectionLinkPin::send($forAdmin->fresh(), 'sms')['error'])->toBe('Avery Admin has no phone number to text the PIN to.');
+
+ // A link whose organisation is gone still sends, with no sender option.
+ $orphan = fleetOpsInspectionControllerLink($form, 'orphan-token', ['company_uuid' => 'company-gone']);
+ $orphan->setPin('998877');
+ $orphan->save();
+ $fakes->smsFails = null;
+ expect(Fleetbase\FleetOps\Support\InspectionLinkPin::send($orphan->fresh(), 'sms')['sent'])->toBeTrue()
+ ->and(end($fakes->sms)['options'])->toBe([]);
+});
+
+test('internal inspection form controller assigns links and sends their pin', function () {
+ $connection = fleetOpsInspectionControllerDatabase();
+ Carbon::setTestNow('2026-09-09 09:00:00');
+ $fakes = fleetOpsInspectionControllerDelivery();
+
+ foreach (['user-admin', 'user-driver'] as $user) {
+ $connection->table('company_users')->insert(['uuid' => 'cu-' . $user, 'company_uuid' => 'company-insp', 'user_uuid' => $user, 'status' => 'active']);
+ }
+ $connection->table('users')->insert(['uuid' => 'user-outsider', 'public_id' => 'user_outsider', 'company_uuid' => 'company-other', 'name' => 'Olly Outsider', 'email' => 'olly@example.com', 'type' => 'user']);
+ $connection->table('company_users')->insert(['uuid' => 'cu-outsider', 'company_uuid' => 'company-other', 'user_uuid' => 'user-outsider', 'status' => 'active']);
+
+ $controller = new InspectionFormController();
+ $form = fleetOpsInspectionControllerForm();
+ $mint = fn (array $input) => $controller->generateLink(fleetOpsInspectionControllerInternalRequest('POST', $input), $form->public_id);
+
+ // Assigned to someone in the organisation, and emailed the link and PIN.
+ $assigned = $mint(['assignee' => 'user_admin', 'pin_delivery' => 'email'])->getData(true);
+ $link = InspectionLink::query()->where('public_id', $assigned['link']['id'])->first();
+ expect($assigned['link']['assignee'])->toBe(['id' => 'user_admin', 'name' => 'Avery Admin'])
+ ->and($assigned['link']['has_pin'])->toBeTrue()
+ ->and($assigned['link']['pin'])->toMatch('/^\d{6}$/')
+ ->and($link->pin)->toBe($assigned['link']['pin'])
+ ->and(password_verify($assigned['link']['pin'], $link->pin_hash))->toBeTrue()
+ ->and($assigned['link']['recipient'])->toBe(['name' => 'Avery Admin'])
+ ->and($assigned['link']['can_send_pin'])->toBe(['email' => true, 'sms' => false])
+ ->and($assigned['link']['pin_sent_via'])->toBe('email')
+ ->and($assigned['pin_delivery'])->toBe(['sent' => true, 'via' => 'email', 'to' => 'a•••@example.com', 'error' => null])
+ ->and($fakes->mail)->toHaveCount(1);
+
+ // A delivery that cannot happen is refused before anything is minted.
+ $before = InspectionLink::query()->count();
+ $nobody = $mint(['pin_delivery' => 'sms']);
+ $noPhone = $mint(['assignee' => 'user_admin', 'pin_delivery' => 'sms']);
+ expect($nobody->getStatusCode())->toBe(422)
+ ->and($nobody->getData(true)['error'])->toContain('Assign the link to someone')
+ ->and($noPhone->getStatusCode())->toBe(422)
+ ->and($noPhone->getData(true)['error'])->toBe('Avery Admin has no phone number to text the PIN to.')
+ ->and(InspectionLink::query()->count())->toBe($before);
+
+ // Not sent at all: the PIN comes back to be shared by hand.
+ $byHand = $mint(['driver' => 'driver_one'])->getData(true);
+ expect($byHand['pin_delivery'])->toBeNull()
+ ->and($byHand['link']['pin'])->toMatch('/^\d{6}$/')
+ ->and($byHand['link']['recipient'])->toBe(['name' => 'Dana Driver'])
+ ->and($byHand['link']['can_send_pin'])->toBe(['email' => true, 'sms' => true]);
+
+ // Nobody outside the organisation can be assigned.
+ expect(fn () => $mint(['assignee' => 'user_outsider']))->toThrow(ModelNotFoundException::class);
+
+ // Sent again from the link list.
+ $send = fn (InspectionLink $to, string $via) => $controller->sendPin(fleetOpsInspectionControllerInternalRequest('POST', ['via' => $via]), $form->public_id, $to->public_id);
+ $driverLink = InspectionLink::query()->where('public_id', $byHand['link']['id'])->first();
+ $resent = $send($driverLink, 'sms')->getData(true);
+ expect($resent['status'])->toBe('ok')
+ ->and($resent['message'])->toBe('PIN sent.')
+ ->and($resent['pin_delivery']['to'])->toBe('•••1111')
+ ->and($resent['link']['pin_sent_via'])->toBe('sms');
+
+ $fakes->mailFails = new RuntimeException('SMTP refused');
+ $failed = $send($driverLink, 'email')->getData(true);
+ expect($failed['status'])->toBe('error')
+ ->and($failed['message'])->toBe('The PIN could not be sent: SMTP refused');
+
+ // Nobody who can receive it, or a link no longer in use, is refused.
+ expect($send($link, 'sms')->getStatusCode())->toBe(422);
+ $link->revoke();
+ $revoked = $send($link->fresh(), 'email');
+ expect($revoked->getStatusCode())->toBe(422)
+ ->and($revoked->getData(true)['error'])->toBe('Only an active link can have its PIN sent.');
+});
+
+test('internal inspection form controller lists and revokes the links minted for a form', function () {
+ $connection = fleetOpsInspectionControllerDatabase();
+ Carbon::setTestNow('2026-09-09 12:00:00');
+ $controller = new InspectionFormController();
+ $form = fleetOpsInspectionControllerForm();
+ $other = fleetOpsInspectionControllerForm(['name' => 'Another form']);
+
+ $active = fleetOpsInspectionControllerLink($form, 'active-token');
+ $expired = fleetOpsInspectionControllerLink($form, 'expired-token', ['expires_at' => '2026-09-09 11:00:00']);
+ $used = fleetOpsInspectionControllerLink($form, 'used-token', ['used_at' => '2026-09-09 10:00:00']);
+ fleetOpsInspectionControllerLink($other, 'other-token');
+ foreach ([[$active, '2026-09-09 09:00:00'], [$expired, '2026-09-09 08:00:00'], [$used, '2026-09-09 07:00:00']] as [$made, $at]) {
+ $connection->table('inspection_links')->where('uuid', $made->uuid)->update(['created_at' => $at]);
+ }
+
+ // Newest first, only this form's, each saying whether it still works.
+ $listed = $controller->links(fleetOpsInspectionControllerInternalRequest('GET', ['limit' => 10]), $form->public_id)->getData(true)['links'];
+ expect(array_column($listed, 'id'))->toBe([$active->public_id, $expired->public_id, $used->public_id])
+ ->and(array_column($listed, 'state'))->toBe(['active', 'expired', 'used'])
+ ->and($controller->links(fleetOpsInspectionControllerInternalRequest('GET', ['limit' => 1]), $form->public_id)->getData(true)['links'])->toHaveCount(1);
+
+ // Revoked by its public id, or by the numeric id an older list handed out.
+ $revoked = $controller->revokeLink(Request::create('/', 'DELETE'), $form->public_id, $active->public_id)->getData(true);
+ expect($revoked['status'])->toBe('ok')
+ ->and($revoked['link']['state'])->toBe('revoked')
+ ->and($active->fresh()->status)->toBe('revoked');
+
+ $numericId = (string) $connection->table('inspection_links')->where('uuid', $expired->uuid)->value('id');
+ $byNumber = $controller->revokeLink(Request::create('/', 'DELETE'), $form->public_id, $numericId)->getData(true);
+ expect($byNumber['link']['id'])->toBe($expired->public_id)
+ ->and($expired->fresh()->status)->toBe('revoked');
+
+ expect(fn () => $controller->revokeLink(Request::create('/', 'DELETE'), $form->public_id, 'inspection_link_missing'))->toThrow(ModelNotFoundException::class);
+});
+
+test('public inspection link asks for its pin, counts wrong ones, and locks', function () {
+ fleetOpsInspectionControllerDatabase();
+ $controller = new PublicInspectionController();
+ $form = fleetOpsInspectionControllerForm();
+ $link = fleetOpsInspectionControllerLink($form, 'pin-token', ['assignee_uuid' => 'user-admin']);
+ $link->setPin('135790');
+ $link->save();
+
+ $show = fn (array $server = [], array $query = []) => $controller->show(
+ Request::create('/public/inspections/forms/x', 'GET', array_merge(['token' => 'pin-token'], $query), [], [], $server),
+ $form->public_id
+ );
+
+ $missing = fleetOpsInspectionControllerRefusal(fn () => $show());
+ expect($missing->getStatusCode())->toBe(403)
+ ->and($missing->getData(true))->toBe(['error' => 'Enter the PIN you were given with this link.', 'pin_required' => true]);
+
+ $wrong = fleetOpsInspectionControllerRefusal(fn () => $show(['HTTP_X_INSPECTION_PIN' => '000000']));
+ expect($wrong->getStatusCode())->toBe(403)
+ ->and($wrong->getData(true))->toBe(['error' => 'That PIN is not right.', 'pin_required' => true, 'attempts_left' => 4]);
+
+ // The right PIN, as a header or a field, opens the form and clears the count.
+ $opened = $show(['HTTP_X_INSPECTION_PIN' => '135790'])->getData(true);
+ expect($opened['identity']['assignee'])->toBe(['id' => 'user_admin', 'name' => 'Avery Admin'])
+ ->and($link->fresh()->pin_attempts)->toBe(0)
+ ->and($show([], ['pin' => '135-790'])->getStatusCode())->toBe(200);
+
+ // The fifth wrong PIN in a row locks the link, and it stays locked.
+ foreach (range(1, InspectionLink::MAX_PIN_ATTEMPTS - 1) as $attempt) {
+ fleetOpsInspectionControllerRefusal(fn () => $show(['HTTP_X_INSPECTION_PIN' => '000000']));
+ }
+ $locked = fleetOpsInspectionControllerRefusal(fn () => $show(['HTTP_X_INSPECTION_PIN' => '000000']));
+ expect($locked->getStatusCode())->toBe(403)
+ ->and($locked->getData(true)['locked'])->toBeTrue()
+ ->and($link->fresh()->status)->toBe('locked');
+
+ $stillLocked = fleetOpsInspectionControllerRefusal(fn () => $show(['HTTP_X_INSPECTION_PIN' => '135790']));
+ expect($stillLocked->getStatusCode())->toBe(403)
+ ->and($stillLocked->getData(true)['locked'])->toBeTrue();
+});
+
+test('public inspection link stores a photo through the link, and refuses what it cannot keep', function () {
+ $connection = fleetOpsInspectionControllerDatabase();
+ $root = fleetOpsInspectionControllerDisk();
+ $controller = new PublicInspectionController();
+ $form = fleetOpsInspectionControllerForm();
+ $link = fleetOpsInspectionControllerLink($form, 'good-token');
+ $upload = fn (array $input = []) => $controller->upload(
+ Request::create('/public/inspections/forms/x/files', 'POST', array_merge(['token' => 'good-token'], $input), [], ['file' => fleetOpsInspectionControllerPhoto()]),
+ $form->public_id
+ );
+
+ // Named photo.php by the device, stored as the PNG its bytes say it is.
+ $stored = $upload(['type' => 'inspection_signature'])->getData(true)['file'];
+ $file = Fleetbase\Models\File::query()->where('public_id', $stored['id'])->first();
+ expect($stored['filename'])->toBe('photo.php')
+ ->and($stored['content_type'])->toBe('image/png')
+ ->and($file->path)->toStartWith('inspections/links/' . $link->uuid . '/')
+ ->and($file->path)->toEndWith('.png')
+ ->and($stored['url'])->toBe('https://files.test/storage/' . $file->path)
+ ->and(is_file($root . '/' . $file->path))->toBeTrue()
+ ->and($file->company_uuid)->toBe('company-insp')
+ ->and($file->uploader_uuid)->toBe('user-driver')
+ ->and($file->type)->toBe('inspection_signature')
+ ->and(data_get($file->meta, 'inspection_link_uuid'))->toBe($link->uuid);
+
+ // A disk that will not take the file says so, and nothing is recorded.
+ $files = Fleetbase\Models\File::query()->count();
+ app()->instance(Illuminate\Contracts\Filesystem\Factory::class, new class {
+ public function disk($name = null)
+ {
+ return new class {
+ public function putFileAs($path, $file, $name = null, $options = [])
+ {
+ return false;
+ }
+ };
+ }
+ });
+ $failed = fleetOpsInspectionControllerRefusal(fn () => $upload());
+ expect($failed->getStatusCode())->toBe(500)
+ ->and($failed->getData(true)['error'])->toBe('This photo could not be stored.')
+ ->and(Fleetbase\Models\File::query()->count())->toBe($files);
+
+ // One link can take only so many files.
+ foreach (range(1, 40) as $n) {
+ $connection->table('files')->insert(['uuid' => 'cap-' . $n, 'public_id' => 'file_cap' . $n, 'company_uuid' => 'company-insp', 'meta' => json_encode(['inspection_link_uuid' => $link->uuid])]);
+ }
+ $capped = fleetOpsInspectionControllerRefusal(fn () => $upload());
+ expect($capped->getStatusCode())->toBe(422)
+ ->and($capped->getData(true)['error'])->toBe('This inspection link has reached its upload limit.');
+});
+
+test('public inspection link claims a single-use link once, and marks a reusable one used', function () {
+ $connection = fleetOpsInspectionControllerDatabase();
+ Carbon::setTestNow('2026-09-09 08:30:00');
+ $controller = new PublicInspectionController();
+ $form = fleetOpsInspectionControllerForm();
+ InspectionFormSync::convertLegacyItems($form);
+ $body = fn (string $token) => ['token' => $token, 'custom_field_values' => [
+ ['custom_field' => 'brakes', 'value_type' => 'object', 'value' => ['passed' => true]],
+ ['custom_field' => 'lights', 'value_type' => 'object', 'value' => ['passed' => true]],
+ ]];
+
+ // A reusable link files, and is marked as used without being spent.
+ $reusable = fleetOpsInspectionControllerLink($form, 'reuse-token', ['single_use' => false]);
+ $controller->submit(Request::create('/x', 'POST', $body('reuse-token'), [], [], ['REMOTE_ADDR' => '198.51.100.4']), $form->public_id);
+ expect($reusable->fresh()->used_at->toDateTimeString())->toBe('2026-09-09 08:30:00')
+ ->and($reusable->fresh()->used_ip)->toBe('198.51.100.4')
+ ->and($reusable->fresh()->isUsable())->toBeTrue();
+
+ // Two submits at once: the other took the link between this one's check
+ // and its claim, so this one is refused and files nothing.
+ $raced = fleetOpsInspectionControllerLink($form, 'race-token');
+ $taken = false;
+ InspectionLink::retrieved(function (InspectionLink $retrieved) use ($connection, $raced, &$taken) {
+ if (!$taken && $retrieved->uuid === $raced->uuid) {
+ $taken = true;
+ $connection->table('inspection_links')->where('uuid', $raced->uuid)->update(['used_at' => '2026-09-09 08:29:59']);
+ }
+ });
+ $before = InspectionSubmission::query()->count();
+ $refused = fleetOpsInspectionControllerRefusal(fn () => $controller->submit(Request::create('/x', 'POST', $body('race-token')), $form->public_id));
+ expect($refused->getStatusCode())->toBe(409)
+ ->and($refused->getData(true)['error'])->toBe('This inspection link has already been used.')
+ ->and(InspectionSubmission::query()->count())->toBe($before);
+});
+
+test('public inspection routes answer in json whatever the client accepts', function () {
+ // The platform's fetch service asks for anything, which on its own would
+ // have a refused request redirected rather than answered in JSON.
+ $untouched = Request::create('/public/inspections/forms/x/submit', 'POST', [], [], [], ['HTTP_ACCEPT' => '*/*']);
+ $forced = Request::create('/public/inspections/forms/x/submit', 'POST', [], [], [], ['HTTP_ACCEPT' => '*/*']);
+
+ $accept = (new Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse())->handle($forced, fn (Request $forwarded) => $forwarded->headers->get('Accept'));
+
+ expect($untouched->expectsJson())->toBeFalse()
+ ->and($accept)->toBe('application/json')
+ ->and($forced->expectsJson())->toBeTrue();
+});
diff --git a/server/tests/InspectionFieldContractsTest.php b/server/tests/InspectionFieldContractsTest.php
new file mode 100644
index 000000000..e5464351f
--- /dev/null
+++ b/server/tests/InspectionFieldContractsTest.php
@@ -0,0 +1,1004 @@
+ str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value)));
+}
+
+if (!class_exists('Fleetbase\\Http\\Requests\\ExportRequest', false)) {
+ eval('namespace Fleetbase\\Http\\Requests; class ExportRequest extends \\Illuminate\\Http\\Request {}');
+}
+
+class FleetOpsInspectionExportRequestFake extends ExportRequest
+{
+}
+
+/**
+ * The submission controller with the download intercepted: what is asserted
+ * is the sheet it asked for, not the workbook the spreadsheet library builds.
+ */
+class FleetOpsInspectionSubmissionControllerProbe extends InspectionSubmissionController
+{
+ public array $downloads = [];
+
+ protected function downloadExport(InspectionExport $export, string $fileName)
+ {
+ $this->downloads[] = [$export, $fileName];
+
+ return ['download' => $fileName, 'headings' => $export->headings()];
+ }
+}
+
+/** Stands in for the route a resource asks whether it is answering. */
+class FleetOpsInspectionFieldRouteFixture
+{
+ public function __construct(private string $uri)
+ {
+ }
+
+ public function uri(): string
+ {
+ return $this->uri;
+ }
+}
+
+/** Binds the request a resource reads to decide public from internal. */
+function fleetOpsInspectionFieldRequest(bool $internal): Request
+{
+ $uri = $internal ? 'api/int/v1/fleet-ops/inspection-submissions' : 'v1/inspections';
+ $request = Request::create('/' . $uri, 'GET');
+ $request->setRouteResolver(fn () => new FleetOpsInspectionFieldRouteFixture($uri));
+ app()->instance('request', $request);
+
+ return $request;
+}
+
+/**
+ * A form built from fields, and a submission that answers it, against an
+ * in-memory database. Every column is a nullable string: what is asserted
+ * here is what the models, the writer and the resources put in and take out.
+ */
+function fleetOpsInspectionFieldDatabase(): SQLiteConnection
+{
+ $pdo = new PDO('sqlite::memory:');
+
+ // A failed inspection raises an issue, and `issues.location` is spatial:
+ // MySQL answers with a 4-byte SRID followed by the geometry's WKB, which is
+ // what the spatial trait parses when the row is read back.
+ $asStoredPoint = function ($wkt, $srid = 0, $axisOrder = null) {
+ if (!preg_match('/POINT\s*\(\s*(-?[\d.]+)\s+(-?[\d.]+)\s*\)/i', (string) $wkt, $pair)) {
+ return $wkt;
+ }
+
+ // WKB: little-endian marker, geometry type 1 (point), then x (lng) and y (lat).
+ return pack('V', (int) $srid) . pack('C', 1) . pack('V', 1) . pack('d', (float) $pair[1]) . pack('d', (float) $pair[2]);
+ };
+ $pdo->sqliteCreateFunction('ST_PointFromText', $asStoredPoint);
+ $pdo->sqliteCreateFunction('ST_GeomFromText', $asStoredPoint);
+
+ $connection = new SQLiteConnection($pdo);
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ EloquentModel::setConnectionResolver($resolver);
+ EloquentModel::setEventDispatcher(new Dispatcher());
+ EloquentModel::clearBootedModels();
+
+ $root = sys_get_temp_dir() . '/fleetops-inspection-files';
+ $config = new Repository([
+ 'activitylog' => ['enabled' => false, 'default_auth_driver' => null, 'default_log_name' => 'default'],
+ 'api' => ['cache' => ['enabled' => false]],
+ // Not the `local` disk: a file on that one is answered through the
+ // foundation's `asset()` helper, which has no app to ask here.
+ 'filesystems' => [
+ 'default' => 'uploads',
+ 'disks' => ['uploads' => ['driver' => 'local', 'root' => $root, 'url' => 'https://files.example.com']],
+ ],
+ ]);
+ app()->instance('config', $config);
+ app()->instance(Illuminate\Contracts\Config\Repository::class, $config);
+ app()->instance(Spatie\Activitylog\CauserResolver::class, new class extends Spatie\Activitylog\CauserResolver {
+ public function __construct()
+ {
+ }
+
+ public function resolve(EloquentModel|int|string|null $subject = null): ?EloquentModel
+ {
+ return null;
+ }
+ });
+ app()->instance('db', new class($connection) {
+ public function __construct(public SQLiteConnection $connection)
+ {
+ }
+
+ public function connection($name = null): SQLiteConnection
+ {
+ return $this->connection;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return $this->connection->{$method}(...$arguments);
+ }
+ });
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+ app()->instance('db.schema', $connection->getSchemaBuilder());
+ app()->instance('responsecache', new class {
+ public function __call($method, $arguments)
+ {
+ return null;
+ }
+ });
+ app()->instance('request', Request::create('/'));
+
+ // Files are real here: the store writes base64 through the platform's
+ // File model, and what it wrote is what the resource resolves back.
+ app()->instance('filesystem', new FilesystemManager(app()));
+ Illuminate\Support\Facades\Storage::clearResolvedInstances();
+ Illuminate\Support\Facades\Storage::swap(app('filesystem'));
+
+ $schema = $connection->getSchemaBuilder();
+ $tables = [
+ 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'],
+ 'inspection_submissions' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'vehicle_uuid', 'driver_uuid', 'submitted_by_uuid', 'issue_uuid', 'work_order_uuid', 'type', 'status', 'result', 'source', 'odometer', 'engine_hours', 'total_items', 'failed_items', 'started_at', 'submitted_at', 'resolved_at', 'location', 'signature', 'attachments', 'meta', 'created_by_uuid', 'updated_by_uuid'],
+ 'inspection_item_results' => ['uuid', '_key', 'company_uuid', 'inspection_submission_uuid', 'issue_uuid', 'work_order_uuid', 'item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos', 'meta', 'created_by_uuid', 'updated_by_uuid'],
+ 'issues' => ['uuid', 'public_id', '_key', 'company_uuid', 'reported_by_uuid', 'assigned_to_uuid', 'vehicle_uuid', 'driver_uuid', 'order_uuid', 'issue_id', 'location', 'category', 'type', 'report', 'title', 'tags', 'priority', 'meta', 'resolved_at', 'status'],
+ 'work_orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'schedule_uuid', 'code', 'subject', 'category', 'status', 'priority', 'target_type', 'target_uuid', 'assignee_type', 'assignee_uuid', 'opened_at', 'due_at', 'closed_at', 'instructions', 'checklist', 'currency', 'estimated_cost', 'approved_budget', 'actual_cost', 'cost_center', 'budget_code', 'meta', 'created_by_uuid', 'updated_by_uuid'],
+ 'vehicles' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'vendor_uuid', 'photo_uuid', 'name', 'make', 'model', 'year', 'trim', 'plate_number', 'vin', 'status', 'currency', 'slug', 'online', 'location'],
+ 'drivers' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'current_job_uuid', 'photo_uuid', 'status', 'online', 'location', 'slug'],
+ 'users' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'email', 'phone', 'avatar_uuid', 'type', 'status'],
+ 'companies' => ['uuid', 'public_id', '_key', 'name', 'owner_uuid'],
+ 'company_users' => ['uuid', '_key', 'company_uuid', 'user_uuid', 'role_uuid', 'status'],
+ 'settings' => ['key', 'value'],
+ 'files' => ['uuid', 'public_id', '_key', 'company_uuid', 'uploader_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'bucket', 'folder', 'etag', 'meta', 'original_filename', 'type', 'content_type', 'file_size', 'slug', 'caption'],
+ 'vendors' => ['uuid', 'public_id', '_key', 'company_uuid', 'name'],
+ 'orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'driver_assigned_uuid', 'status'],
+ 'positions' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_uuid', 'subject_type', 'coordinates'],
+ 'custom_field_values' => ['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'],
+ 'custom_fields' => ['uuid', 'company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order'],
+ 'categories' => ['uuid', 'public_id', '_key', 'company_uuid', 'owner_uuid', 'owner_type', 'parent_uuid', 'icon_file_uuid', 'internal_id', 'name', 'description', 'translations', 'meta', 'tags', 'icon', 'icon_color', 'slug', 'order', 'for', 'core_category'],
+ 'activity_log' => ['uuid', 'company_uuid', 'log_name', 'description', 'subject_type', 'subject_id', 'causer_type', 'causer_id', 'properties', 'event', 'batch_uuid'],
+ ];
+
+ foreach ($tables as $table => $columns) {
+ $schema->create($table, function ($blueprint) use ($columns) {
+ $blueprint->increments('id');
+ foreach ($columns as $column) {
+ $blueprint->string($column)->nullable();
+ }
+ $blueprint->timestamps();
+ $blueprint->timestamp('deleted_at')->nullable();
+ });
+ }
+
+ $connection->table('companies')->insert(['uuid' => 'company-insp', 'public_id' => 'company_insp', 'name' => 'Inspection Co']);
+ $connection->table('users')->insert(['uuid' => 'user-driver', 'public_id' => 'user_driver', 'company_uuid' => 'company-insp', 'name' => 'Dana Driver', 'email' => 'dana@example.com', 'type' => 'user']);
+ $connection->table('vehicles')->insert(['uuid' => 'vehicle-1', 'public_id' => 'vehicle_one', 'company_uuid' => 'company-insp', 'name' => 'Truck 7', 'plate_number' => 'TRK-7']);
+ $connection->table('drivers')->insert(['uuid' => 'driver-1', 'public_id' => 'driver_one', 'company_uuid' => 'company-insp', 'user_uuid' => 'user-driver', 'vehicle_uuid' => 'vehicle-1', 'status' => 'active']);
+
+ session(['company' => 'company-insp', 'user' => 'user-driver']);
+
+ return $connection;
+}
+
+/** The draft the console builder posts: two groups of typed fields. */
+function fleetOpsInspectionFieldDraft(): array
+{
+ return [
+ [
+ 'name' => 'Exterior',
+ 'order' => 1,
+ 'meta' => ['grid_size' => 2],
+ 'fields' => [
+ ['label' => 'Mirrors', 'name' => 'mirrors', 'type' => 'pass-fail', 'required' => true, 'order' => 1, 'meta' => ['severity' => 'medium', 'require_comment_on_fail' => true, 'unsafe_on_fail' => false]],
+ ['label' => 'Brakes', 'name' => 'brakes', 'type' => 'pass-fail', 'required' => true, 'order' => 2, 'meta' => ['severity' => 'critical', 'require_photo_on_fail' => true, 'require_comment_on_fail' => true, 'unsafe_on_fail' => true]],
+ ],
+ ],
+ [
+ 'name' => 'Meter and sign-off',
+ 'order' => 2,
+ 'meta' => ['grid_size' => 1],
+ // `customFields` is the fliit builder's spelling; both are read.
+ 'customFields' => [
+ ['label' => 'Odometer', 'type' => 'number', 'order' => 1, 'meta' => ['unit' => 'km', 'role' => 'odometer']],
+ ['label' => 'Notes', 'type' => 'textarea', 'order' => 2],
+ ['label' => 'Sign here', 'name' => 'signature', 'type' => 'signature', 'order' => 3],
+ ['label' => 'Tail lift photo', 'name' => 'tail_lift', 'type' => 'file-upload', 'order' => 4],
+ ['label' => 'Trailer attached', 'name' => 'trailer', 'type' => 'boolean', 'order' => 5],
+ ['label' => 'Fuel level', 'name' => 'fuel', 'type' => 'radio-button', 'options' => ['full', 'half'], 'order' => 6],
+ ],
+ ],
+ ];
+}
+
+function fleetOpsInspectionFieldForm(array $attributes = []): InspectionForm
+{
+ return InspectionForm::create(array_merge([
+ 'company_uuid' => 'company-insp',
+ 'name' => 'Pre-trip DVIR',
+ 'type' => 'dvir',
+ 'status' => 'published',
+ 'published_at' => '2026-09-01 08:00:00',
+ 'settings' => ['create_issue_on_failure' => false, 'create_work_order_on_failure' => false],
+ ], $attributes));
+}
+
+function fleetOpsInspectionFieldSubmission(InspectionForm $form, array $attributes = []): InspectionSubmission
+{
+ return InspectionSubmission::create(array_merge([
+ 'company_uuid' => 'company-insp',
+ 'inspection_form_uuid' => $form->uuid,
+ 'vehicle_uuid' => 'vehicle-1',
+ 'driver_uuid' => 'driver-1',
+ 'submitted_by_uuid' => 'user-driver',
+ 'type' => 'dvir',
+ 'status' => 'draft',
+ 'source' => 'navigator',
+ ], $attributes));
+}
+
+/** A one-pixel PNG, as the app sends one. */
+function fleetOpsInspectionFieldPhoto(): string
+{
+ return base64_encode(base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', true));
+}
+
+afterEach(function () {
+ Carbon::setTestNow();
+});
+
+test('the form writer builds groups of typed fields and prunes what a later draft drops', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm();
+
+ $written = InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft());
+
+ expect($written['groups'])->toHaveCount(2)
+ ->and($written['fields'])->toHaveCount(8);
+
+ $groups = $form->fieldGroups;
+ expect($groups->pluck('name')->all())->toBe(['Exterior', 'Meter and sign-off'])
+ ->and($groups->first()->for)->toBe(InspectionForm::GROUP_FOR)
+ ->and($groups->first()->owner_uuid)->toBe($form->uuid)
+ ->and($groups->first()->meta)->toBe(['grid_size' => 2])
+ ->and($groups->first()->public_id)->toStartWith('category_')
+ ->and($groups->first()->slug)->toBe('exterior');
+
+ $fields = $form->fields->keyBy('name');
+ expect($fields->keys()->sort()->values()->all())->toBe(['brakes', 'fuel', 'mirrors', 'notes', 'odometer', 'signature', 'tail-lift', 'trailer'])
+ ->and($fields['brakes']->for)->toBe(InspectionForm::FIELD_FOR)
+ ->and($fields['brakes']->subject_uuid)->toBe($form->uuid)
+ ->and($fields['brakes']->category_uuid)->toBe($groups->first()->uuid)
+ ->and($fields['brakes']->required)->toBeTrue()
+ ->and($fields['brakes']->editable)->toBeTrue()
+ ->and($fields['brakes']->component)->toBe('pass-fail')
+ // A field named only by its label takes a slug of the label.
+ ->and($fields['odometer']->label)->toBe('Odometer')
+ ->and($fields['odometer']->meta)->toBe(['unit' => 'km', 'role' => 'odometer'])
+ // `radio-button` is the type; the platform's component is named differently.
+ ->and($fields['fuel']->component)->toBe('radio-button-select')
+ ->and($fields['fuel']->options)->toBe(['full', 'half']);
+
+ // A custom field the console's generic panel added to the form record has
+ // the same subject; the builder must not take it for one of its own.
+ CustomField::create(['company_uuid' => 'company-insp', 'subject_uuid' => $form->uuid, 'subject_type' => $form->getMorphClass(), 'name' => 'depot', 'label' => 'Depot', 'type' => 'text']);
+
+ // A second post with the same uuids updates rather than duplicates, and
+ // what it no longer lists is deleted.
+ $draft = fleetOpsInspectionFieldDraft();
+ $draft[0]['uuid'] = $groups->first()->uuid;
+ $draft[0]['name'] = 'Exterior walk-around';
+ $draft[0]['fields'][0]['uuid'] = $fields['mirrors']->uuid;
+ $draft[0]['fields'][0]['label'] = 'Mirrors and glass';
+ unset($draft[1]);
+
+ InspectionFormSync::sync($form, $draft, true);
+
+ $form->unsetRelation('fieldGroups');
+ $form->unsetRelation('fields');
+ expect($form->fieldGroups)->toHaveCount(1)
+ ->and($form->fieldGroups->first()->name)->toBe('Exterior walk-around')
+ ->and($form->fields)->toHaveCount(2)
+ ->and($form->fields->firstWhere('uuid', $fields['mirrors']->uuid)->label)->toBe('Mirrors and glass')
+ // Two inspection fields, plus the unrelated one, left where it was.
+ ->and(CustomField::query()->count())->toBe(3)
+ ->and(CustomField::query()->where('name', 'depot')->exists())->toBeTrue()
+ ->and(Category::query()->count())->toBe(1);
+});
+
+test('the form writer gives a nameless, typeless field somewhere to go', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm();
+
+ InspectionFormSync::sync($form, [['fields' => [['type' => 'nonsense'], []]]]);
+
+ $group = $form->fieldGroups->first();
+ expect($group->name)->toBeNull()
+ ->and($group->order)->toBe('1')
+ ->and($form->fields->pluck('label')->all())->toBe(['Untitled field', 'Untitled field'])
+ ->and($form->fields->pluck('type')->all())->toBe(['input', 'input'])
+ ->and(InspectionFormSync::componentFor('signature'))->toBe('signature');
+});
+
+test('a first-cut checklist becomes a Checklist group of pass-fail fields, once', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm(['items' => [
+ ['key' => 'brakes', 'label' => 'Brakes', 'category' => 'Safety', 'severity' => 'critical'],
+ ['title' => 'Lights', 'severity' => 'medium', 'required' => false, 'description' => 'All round'],
+ [],
+ ]]);
+
+ expect(InspectionFormSync::convertLegacyItems($form))->toBe(3);
+
+ $group = $form->fresh()->fieldGroups->first();
+ expect($group->name)->toBe('Checklist')
+ ->and($group->meta)->toBe(['grid_size' => 1, 'converted_from_items' => true]);
+
+ $fields = $form->fields()->get()->keyBy('name');
+ expect($fields['brakes']->type)->toBe('pass-fail')
+ ->and($fields['brakes']->required)->toBeTrue()
+ ->and($fields['brakes']->meta['severity'])->toBe('critical')
+ ->and($fields['brakes']->meta['category'])->toBe('Safety')
+ ->and($fields['brakes']->meta['unsafe_on_fail'])->toBeTrue()
+ ->and($fields['lights']->required)->toBeFalse()
+ ->and($fields['lights']->description)->toBe('All round')
+ ->and($fields['lights']->meta['unsafe_on_fail'])->toBeFalse()
+ ->and($fields['item-3']->label)->toBe('Item 3');
+
+ // Idempotent: a form already built with fields is left alone, and so is a
+ // form with nothing to convert.
+ expect(InspectionFormSync::convertLegacyItems($form->fresh()))->toBe(0)
+ ->and(InspectionFormSync::convertLegacyItems(fleetOpsInspectionFieldForm()))->toBe(0)
+ ->and(CustomField::query()->count())->toBe(3);
+});
+
+test('a form answers with its groups in order, ungrouped fields last', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm();
+ InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft());
+
+ // A group and a field with no order of their own sort after the ones that
+ // have one, oldest first; a field belonging to no group is gathered up.
+ Category::query()->where('name', 'Exterior')->update(['order' => null]);
+ CustomField::query()->where('name', 'brakes')->update(['category_uuid' => null, 'order' => null]);
+
+ $form->unsetRelation('fieldGroups');
+ $form->unsetRelation('fields');
+ $groups = $form->grouped_fields;
+
+ expect(collect($groups)->pluck('name')->all())->toBe(['Meter and sign-off', 'Exterior', 'Ungrouped'])
+ ->and($groups[2]->exists)->toBeFalse()
+ ->and($groups[2]->getRelation('fields')->pluck('name')->all())->toBe(['brakes'])
+ ->and($groups[1]->getRelation('fields')->pluck('name')->all())->toBe(['mirrors'])
+ ->and($form->item_count)->toBe(8);
+
+ // The count falls back to the legacy checklist only while there are no fields.
+ $legacy = fleetOpsInspectionFieldForm(['items' => [['key' => 'a', 'label' => 'A']]]);
+ expect($legacy->item_count)->toBe(1);
+});
+
+test('the form resource answers grouped_fields for the driver and identifiers for the console', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm();
+ InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft());
+
+ $public = (new InspectionFormResource($form->fresh()))->toArray(fleetOpsInspectionFieldRequest(false));
+
+ expect($public['id'])->toBe($form->public_id)
+ ->and($public['grouped_fields'])->toHaveCount(2)
+ ->and($public['grouped_fields'][0]['name'])->toBe('Exterior')
+ ->and($public['grouped_fields'][0]['order'])->toBe(1)
+ ->and($public['grouped_fields'][0]['meta'])->toBe(['grid_size' => 2])
+ ->and($public['grouped_fields'][0])->not->toHaveKey('company_uuid');
+
+ $field = $public['grouped_fields'][0]['fields'][1];
+ expect($field['id'])->toBe($field['uuid'])
+ ->and($field['name'])->toBe('brakes')
+ ->and($field['label'])->toBe('Brakes')
+ ->and($field['type'])->toBe('pass-fail')
+ ->and($field['required'])->toBeTrue()
+ ->and($field['editable'])->toBeTrue()
+ ->and($field['options'])->toBe([])
+ ->and($field['order'])->toBe(2)
+ ->and($field['meta']['severity'])->toBe('critical')
+ ->and($field)->not->toHaveKey('category_uuid');
+
+ // A field written straight into the table carries no component; the type names one.
+ CustomField::query()->where('name', 'brakes')->update(['component' => null, 'meta' => null, 'order' => null, 'editable' => null]);
+ $bare = (new InspectionFormResource($form->fresh()))->toArray(fleetOpsInspectionFieldRequest(false))['grouped_fields'][0]['fields'];
+ $bare = collect($bare)->firstWhere('name', 'brakes');
+ expect($bare['component'])->toBe('pass-fail')
+ ->and($bare['meta'])->toEqual((object) [])
+ ->and($bare['order'])->toBeNull()
+ ->and($bare['editable'])->toBeTrue();
+
+ $request = fleetOpsInspectionFieldRequest(true);
+ $internal = (new InspectionFormResource($form->fresh()->load(['fieldGroups', 'fields'])))->toArray($request);
+ expect($internal['grouped_fields'][0]['company_uuid'])->toBe('company-insp')
+ ->and($internal['grouped_fields'][0]['for'])->toBe(InspectionForm::GROUP_FOR)
+ ->and($internal['field_groups'])->toHaveCount(2)
+ ->and($internal['field_groups'][0])->not->toHaveKey('fields')
+ ->and($internal['fields'])->toHaveCount(8)
+ ->and($internal['fields'][0]['subject_uuid'])->toBe($form->uuid)
+ ->and($internal['fields'][0]['for'])->toBe(InspectionForm::FIELD_FOR);
+
+ // A group with no fields loaded answers with none rather than reaching for them.
+ $orphan = Category::query()->where('name', 'Exterior')->first();
+ expect(InspectionFormResource::groupToArray($orphan, false)['fields'])->toBe([]);
+});
+
+test('the file store turns what a driver sends into a platform file', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm();
+ $submission = fleetOpsInspectionFieldSubmission($form);
+
+ $stored = InspectionFileStore::normalize(fleetOpsInspectionFieldPhoto(), $submission, InspectionFileStore::TYPE_PHOTO, 'user-driver');
+ expect($stored)->toStartWith('file:');
+
+ $file = InspectionFileStore::resolve($stored);
+ expect($file)->toBeInstanceOf(File::class)
+ ->and($file->subject_uuid)->toBe($submission->uuid)
+ ->and($file->type)->toBe('inspection_photo')
+ ->and($file->company_uuid)->toBe('company-insp')
+ ->and($file->uploader_uuid)->toBe('user-driver')
+ ->and($file->content_type)->toBe('image/png')
+ ->and($file->path)->toStartWith('inspections/' . $submission->uuid . '/');
+
+ // A URL, or anything that is not a string at all, is left alone. A
+ // reference is kept only for a file the submission may use; one that
+ // names no file in its company is dropped rather than kept as given,
+ // because a kept reference is what the submission resource hands out.
+ expect(InspectionFileStore::normalize($stored, $submission))->toBe($stored)
+ ->and(InspectionFileStore::normalize('https://cdn.example.com/a.jpg', $submission))->toBe('https://cdn.example.com/a.jpg')
+ ->and(InspectionFileStore::normalize(' ', $submission))->toBe(' ')
+ ->and(InspectionFileStore::normalize(42, $submission))->toBe(42)
+ ->and(InspectionFileStore::normalize('not base64!', $submission))->toBe('not base64!')
+ ->and(InspectionFileStore::normalize($file->uuid, $submission))->toBe('file:' . $file->uuid)
+ ->and(InspectionFileStore::normalize($file->public_id, $submission))->toBe('file:' . $file->uuid)
+ ->and(InspectionFileStore::normalize('file_missing', $submission))->toBeNull()
+ // The console uploads a photo as soon as it is picked and keeps the
+ // reference the upload answered with, which names the file by its
+ // public id; that is rewritten to the uuid everything else reads.
+ ->and(InspectionFileStore::normalize('file:' . $file->public_id, $submission))->toBe('file:' . $file->uuid)
+ ->and(InspectionFileStore::normalize('file:file_missing', $submission))->toBeNull();
+
+ // A data URI carries its own content type; bare base64 is sniffed.
+ $jpeg = InspectionFileStore::resolve(InspectionFileStore::normalize('data:image/jpeg;base64,' . fleetOpsInspectionFieldPhoto(), $submission, InspectionFileStore::TYPE_SIGNATURE));
+ expect($jpeg->content_type)->toBe('image/jpeg')
+ ->and($jpeg->type)->toBe('inspection_signature')
+ ->and($jpeg->path)->toEndWith('.jpg')
+ ->and($jpeg->uploader_uuid)->toBe('user-driver');
+
+ expect(InspectionFileStore::sniffContentType(base64_encode("\xFF\xD8\xFF" . str_repeat('a', 20))))->toBe('image/jpeg')
+ ->and(InspectionFileStore::sniffContentType(base64_encode('GIF89a' . str_repeat('a', 20))))->toBe('image/gif')
+ ->and(InspectionFileStore::sniffContentType(base64_encode('RIFF____WEBP' . str_repeat('a', 20))))->toBe('image/webp')
+ ->and(InspectionFileStore::sniffContentType(base64_encode('%PDF-1.4' . str_repeat('a', 20))))->toBe('application/pdf')
+ ->and(InspectionFileStore::sniffContentType(base64_encode(str_repeat('a', 24))))->toBe('image/png')
+ ->and(InspectionFileStore::extensionFor('image/gif'))->toBe('gif')
+ ->and(InspectionFileStore::extensionFor('image/webp'))->toBe('webp')
+ ->and(InspectionFileStore::extensionFor('application/pdf'))->toBe('pdf')
+ ->and(InspectionFileStore::extensionFor('image/svg+xml'))->toBe('svg')
+ ->and(InspectionFileStore::isBase64('data:image/png;base64,***'))->toBeFalse()
+ ->and(InspectionFileStore::isUrl('ftp://x'))->toBeFalse();
+
+ // A file uploaded before the submission existed is claimed by it.
+ $loose = File::create(['company_uuid' => 'company-insp', 'disk' => 'uploads', 'path' => 'loose.png', 'type' => 'inspection_photo']);
+ expect(InspectionFileStore::attachReferenced($submission, ['file:' . $loose->uuid, 'nonsense']))->toBe(0)
+ ->and(InspectionFileStore::attachReferenced($submission, [$loose->uuid]))->toBe(1)
+ ->and(InspectionFileStore::attachReferenced($submission, []))->toBe(0)
+ ->and($loose->fresh()->subject_uuid)->toBe($submission->uuid);
+
+ expect(InspectionFileStore::referencedUuid('file:not-a-uuid'))->toBeNull()
+ ->and(InspectionFileStore::referencedUuid(null))->toBeNull()
+ ->and(InspectionFileStore::resolve('file:' . (string) Illuminate\Support\Str::uuid()))->toBeNull()
+ ->and(InspectionFileStore::project('https://cdn.example.com/a.jpg'))->toBe('https://cdn.example.com/a.jpg');
+
+ $projected = InspectionFileStore::project($stored);
+ expect($projected['id'])->toBe($file->public_id)
+ ->and($projected['content_type'])->toBe('image/png')
+ ->and($projected)->toHaveKeys(['id', 'url', 'filename', 'content_type']);
+});
+
+test('a submission answers a form of fields, and the item results follow', function () {
+ fleetOpsInspectionFieldDatabase();
+ Carbon::setTestNow('2026-09-10 07:00:00');
+ $form = fleetOpsInspectionFieldForm();
+ InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft());
+ $fields = $form->fields->keyBy('name');
+ $submission = fleetOpsInspectionFieldSubmission($form);
+
+ InspectionSubmitter::applyCustomFieldValues($submission, [
+ ['custom_field' => $fields['mirrors']->uuid, 'value_type' => 'object', 'value' => ['passed' => true, 'not_applicable' => false, 'severity' => null, 'comments' => null, 'photos' => [], 'unsafe' => false]],
+ ['custom_field' => $fields['brakes']->uuid, 'value_type' => 'object', 'value' => ['passed' => false, 'severity' => 'Critical', 'comments' => 'Soft pedal', 'photos' => [fleetOpsInspectionFieldPhoto()], 'unsafe' => true]],
+ // Named by its slug rather than its uuid, as an older build may.
+ ['custom_field' => 'odometer', 'value_type' => 'number', 'value' => '112480'],
+ ['custom_field' => $fields['notes']->uuid, 'value_type' => 'text', 'value' => 'Nearside mirror scuffed'],
+ ['custom_field' => $fields['signature']->uuid, 'value_type' => 'file', 'value' => fleetOpsInspectionFieldPhoto()],
+ ['custom_field' => $fields['trailer']->uuid, 'value_type' => 'boolean', 'value' => 'true'],
+ ['custom_field' => $fields['fuel']->uuid, 'value' => ['full']],
+ ], 'user-driver');
+
+ $submission = $submission->fresh(['itemResults', 'customFieldValues.customField', 'files']);
+
+ expect($submission->customFieldValues)->toHaveCount(7)
+ ->and($submission->total_items)->toBe(2)
+ ->and($submission->failed_items)->toBe(1)
+ ->and($submission->result)->toBe('failed')
+ ->and($submission->status)->toBe('submitted')
+ ->and($submission->meta['unsafe'])->toBeTrue()
+ ->and($submission->files)->toHaveCount(2);
+
+ $results = $submission->itemResults->keyBy('item_key');
+ expect($results->keys()->sort()->values()->all())->toBe(['brakes', 'mirrors'])
+ ->and($results['brakes']->label)->toBe('Brakes')
+ ->and($results['brakes']->category)->toBe('Exterior')
+ ->and($results['brakes']->status)->toBe('failed')
+ ->and($results['brakes']->severity)->toBe('critical')
+ ->and($results['brakes']->passed)->toBeFalse()
+ ->and($results['brakes']->comments)->toBe('Soft pedal')
+ ->and($results['brakes']->photos[0])->toStartWith('file:')
+ ->and($results['brakes']->meta['unsafe'])->toBeTrue()
+ ->and($results['brakes']->meta['custom_field_uuid'])->toBe($fields['brakes']->uuid)
+ ->and($results['mirrors']->status)->toBe('passed')
+ ->and($results['mirrors']->severity)->toBeNull();
+
+ // The value column is a string, so a meter reading is stored as one; the
+ // resource is where it becomes a number again.
+ $odometer = $submission->customFieldValues->first(fn ($value) => $value->custom_field_uuid === $fields['odometer']->uuid);
+ expect($odometer->value)->toBe('112480')
+ ->and($odometer->value_type)->toBe('number');
+
+ // Answering again with the brakes passing and the mirrors not applicable
+ // rewrites the results rather than adding to them.
+ InspectionSubmitter::applyCustomFieldValues($submission, [
+ ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => true, 'not_applicable' => true]],
+ ['custom_field' => $fields['brakes']->uuid, 'value' => ['passed' => true]],
+ ]);
+
+ $submission = $submission->fresh(['itemResults']);
+ $results = $submission->itemResults->keyBy('item_key');
+ expect($submission->itemResults)->toHaveCount(2)
+ ->and($results['mirrors']->status)->toBe('not_applicable')
+ ->and($results['mirrors']->passed)->toBeTrue()
+ ->and($results['brakes']->status)->toBe('passed')
+ ->and($results['brakes']->severity)->toBeNull()
+ ->and($submission->meta['unsafe'])->toBeFalse()
+ ->and($submission->result)->toBe('passed');
+
+ // Clearing an answer is not an answer: the value row goes rather than a
+ // null being written to a column that will not take one.
+ InspectionSubmitter::applyCustomFieldValues($submission, [
+ ['custom_field' => $fields['notes']->uuid, 'value' => null],
+ ['custom_field' => $fields['odometer']->uuid, 'value' => ''],
+ ]);
+ expect($submission->fresh()->customFieldValues()->where('custom_field_uuid', $fields['notes']->uuid)->count())->toBe(0)
+ ->and($submission->fresh()->customFieldValues()->where('custom_field_uuid', $fields['odometer']->uuid)->count())->toBe(0);
+
+ // An answer that is taken away — the console clearing a field — takes its
+ // result row with it; a row written any other way is left alone.
+ $submission->customFieldValues()->where('custom_field_uuid', $fields['mirrors']->uuid)->delete();
+ $submission->itemResults()->create(['company_uuid' => 'company-insp', 'item_key' => 'hand-written', 'label' => 'Hand written', 'passed' => true]);
+ expect($submission->fresh()->syncItemResultsFromCustomFieldValues())->toBe(1)
+ ->and($submission->fresh()->itemResults()->pluck('item_key')->sort()->values()->all())->toBe(['brakes', 'hand-written']);
+});
+
+test('a submission refuses an answer the form will not accept', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm();
+ InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft());
+ $fields = $form->fields->keyBy('name');
+ $submission = fleetOpsInspectionFieldSubmission($form);
+
+ $refusal = null;
+ try {
+ InspectionSubmitter::applyCustomFieldValues($submission, [
+ ['custom_field' => 'not-a-field-of-this-form', 'value' => 1],
+ ['custom_field' => $fields['brakes']->uuid, 'value' => ['passed' => false]],
+ ['value' => 'no field at all'],
+ ]);
+ } catch (ValidationException $exception) {
+ $refusal = $exception->errors();
+ }
+
+ expect($refusal['custom_field_values.0.custom_field'][0])->toContain('not-a-field-of-this-form')
+ ->and($refusal['custom_field_values.1.value'])->toBe([
+ 'A comment is required when "Brakes" fails.',
+ 'A photo is required when "Brakes" fails.',
+ ])
+ ->and($refusal['custom_field_values.2.custom_field'][0])->toContain('"?"')
+ // Nothing was written, and no photo was stored on the way to refusing.
+ ->and($submission->customFieldValues()->count())->toBe(0)
+ ->and(File::query()->count())->toBe(0);
+});
+
+test('a pass-fail answer is read whatever shape it arrives in', function () {
+ expect(InspectionSubmitter::passFailAnswer(['passed' => false, 'severity' => 'High', 'comments' => 'x', 'photos' => ['a'], 'unsafe' => true]))
+ ->toBe(['passed' => false, 'not_applicable' => false, 'severity' => 'high', 'comments' => 'x', 'photos' => ['a'], 'unsafe' => true])
+ ->and(InspectionSubmitter::passFailAnswer('{"pass":false}')['passed'])->toBeFalse()
+ ->and(InspectionSubmitter::passFailAnswer('{not json')['passed'])->toBeTrue()
+ ->and(InspectionSubmitter::passFailAnswer('fail')['passed'])->toBeFalse()
+ ->and(InspectionSubmitter::passFailAnswer('pass')['passed'])->toBeTrue()
+ ->and(InspectionSubmitter::passFailAnswer(false)['passed'])->toBeFalse()
+ ->and(InspectionSubmitter::passFailAnswer(['passed' => null])['not_applicable'])->toBeTrue()
+ ->and(InspectionSubmitter::passFailAnswer(['na' => true])['not_applicable'])->toBeTrue()
+ ->and(InspectionSubmitter::passFailAnswer(['severity' => ''])['severity'])->toBeNull();
+});
+
+test('the submission resource answers the answers, with the files resolved', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm();
+ InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft());
+ $fields = $form->fields->keyBy('name');
+ $submission = fleetOpsInspectionFieldSubmission($form);
+
+ InspectionSubmitter::applyCustomFieldValues($submission, [
+ ['custom_field' => $fields['brakes']->uuid, 'value' => ['passed' => false, 'severity' => 'critical', 'comments' => 'Soft pedal', 'photos' => [fleetOpsInspectionFieldPhoto()]]],
+ ['custom_field' => $fields['signature']->uuid, 'value_type' => 'file', 'value' => fleetOpsInspectionFieldPhoto()],
+ ['custom_field' => $fields['notes']->uuid, 'value' => 'Nothing else to report'],
+ ['custom_field' => $fields['odometer']->uuid, 'value_type' => 'number', 'value' => '112480'],
+ ['custom_field' => $fields['trailer']->uuid, 'value' => 'true'],
+ ], 'user-driver');
+
+ $loaded = $submission->fresh(['itemResults', 'customFieldValues.customField', 'files']);
+ $public = (new InspectionSubmissionResource($loaded))->toArray(fleetOpsInspectionFieldRequest(false));
+
+ $values = collect($public['custom_field_values'])->keyBy('name');
+ expect($public['custom_field_values'])->toHaveCount(5)
+ ->and($values['brakes']['custom_field'])->toBe($fields['brakes']->uuid)
+ ->and($values['brakes']['label'])->toBe('Brakes')
+ ->and($values['brakes']['type'])->toBe('pass-fail')
+ ->and($values['brakes']['value']['passed'])->toBeFalse()
+ ->and($values['brakes']['value']['comments'])->toBe('Soft pedal')
+ ->and($values['brakes']['value']['photos'][0])->toHaveKeys(['id', 'url', 'filename', 'content_type'])
+ ->and($values['signature']['value'])->toHaveKeys(['id', 'url', 'filename', 'content_type'])
+ ->and($values['notes']['value'])->toBe('Nothing else to report')
+ // A value column is a string; the resource hands back the number and
+ // the boolean the app wrote.
+ ->and($values['odometer']['value'])->toBe(112480)
+ ->and($values['trailer']['value'])->toBeTrue()
+ ->and($values['brakes'])->not->toHaveKey('uuid');
+
+ expect($public['files'])->toHaveCount(2)
+ ->and($public['files'][0])->toHaveKeys(['id', 'uuid', 'url', 'original_filename', 'content_type', 'type'])
+ ->and($public['item_results'])->toHaveCount(1);
+
+ $internal = (new InspectionSubmissionResource($loaded))->toArray(fleetOpsInspectionFieldRequest(true));
+ $brakes = collect($internal['custom_field_values'])->firstWhere('name', 'brakes');
+ expect($brakes['uuid'])->not->toBeNull()
+ ->and($brakes['category_uuid'])->toBe($fields['brakes']->category_uuid)
+ ->and($brakes['order'])->toBe(2)
+ ->and($brakes['meta']['severity'])->toBe('critical');
+
+ // A submission whose answers were never loaded says nothing about them.
+ $bare = (new InspectionSubmissionResource(new InspectionSubmission()))->toArray(fleetOpsInspectionFieldRequest(false));
+ expect($bare['custom_field_values'])->toBe([])
+ ->and($bare['files'])->toBe([]);
+});
+
+test('the submission resource survives a value whose field is gone or whose object is not one', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm();
+ InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft());
+ $fields = $form->fields->keyBy('name');
+ $submission = fleetOpsInspectionFieldSubmission($form);
+
+ $submission->syncCustomFieldValues([
+ ['custom_field_uuid' => $fields['notes']->uuid, 'value' => 'not json', 'value_type' => 'object'],
+ ['custom_field_uuid' => (string) Illuminate\Support\Str::uuid(), 'value' => 'orphan', 'value_type' => 'text'],
+ ]);
+
+ $loaded = $submission->fresh(['customFieldValues.customField']);
+ $payload = (new InspectionSubmissionResource($loaded))->toArray(fleetOpsInspectionFieldRequest(true));
+ $rows = collect($payload['custom_field_values']);
+
+ expect($rows)->toHaveCount(2)
+ ->and($rows->firstWhere('name', 'notes')['value'])->toBe('not json')
+ ->and($rows->last()['label'])->toBeNull()
+ ->and($rows->last()['type'])->toBe('text')
+ ->and($rows->last()['value'])->toBe('orphan')
+ ->and($rows->last()['meta'])->toEqual((object) []);
+
+ expect($submission->fresh()->referencedFileUuids())->toBe([]);
+});
+
+test('the inspection export names the defects and whether the truck was parked', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm();
+ InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft());
+ $fields = $form->fields->keyBy('name');
+
+ $failed = fleetOpsInspectionFieldSubmission($form, ['odometer' => 112480, 'engine_hours' => 3100]);
+ InspectionSubmitter::applyCustomFieldValues($failed, [
+ ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => true]],
+ ['custom_field' => $fields['brakes']->uuid, 'value' => ['passed' => false, 'comments' => 'Soft pedal', 'photos' => [fleetOpsInspectionFieldPhoto()]]],
+ ]);
+ $passed = fleetOpsInspectionFieldSubmission($form);
+ InspectionSubmitter::applyCustomFieldValues($passed, [
+ ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => true]],
+ ]);
+
+ $export = new InspectionExport();
+ $rows = $export->collection();
+ expect($rows)->toHaveCount(2)
+ ->and($export->headings())->toHaveCount(20)
+ ->and($export->columnFormats())->toHaveKeys(['Q', 'R', 'S', 'T']);
+
+ $mapped = collect($rows)->map(fn ($row) => $export->map($row))->keyBy(0);
+ $row = $mapped[$failed->public_id];
+ expect($row[1])->toBe('Pre-trip DVIR')
+ ->and($row[2])->toBe('Truck 7')
+ ->and($row[3])->toBe('Dana Driver')
+ ->and($row[6])->toBe('failed')
+ ->and($row[8])->toBe(112480)
+ ->and($row[9])->toBe(3100)
+ ->and($row[11])->toBe(1)
+ ->and($row[12])->toBe('Brakes')
+ ->and($row[13])->toBe('Yes')
+ ->and($mapped[$passed->public_id][12])->toBe('')
+ ->and($mapped[$passed->public_id][13])->toBe('No');
+
+ // A selection narrows the sheet to the rows the console ticked.
+ expect((new InspectionExport([$failed->uuid]))->collection())->toHaveCount(1);
+});
+
+test('the internal form controller writes the builder draft and reads the structure back', function () {
+ fleetOpsInspectionFieldDatabase();
+ $controller = new InspectionFormController();
+ $form = fleetOpsInspectionFieldForm();
+
+ // The builder posts the whole form under the record it is saving.
+ $controller->onAfterCreate(Request::create('/int/v1/inspection-forms', 'POST', ['inspection_form' => ['field_groups' => fleetOpsInspectionFieldDraft()]]), $form);
+ expect($form->fieldGroups)->toHaveCount(2)
+ ->and($form->fields)->toHaveCount(8);
+
+ // A save that mentions no structure leaves the structure alone: publishing
+ // a form must not empty it.
+ $controller->onAfterUpdate(Request::create('/int/v1/inspection-forms/x', 'PUT', ['inspection_form' => ['status' => 'published']]), $form);
+ expect($form->fresh()->fields()->count())->toBe(8);
+
+ // fliit's builder posts the same thing under `draft`, and a form authored
+ // there still saves; what it drops is pruned.
+ $controller->onAfterUpdate(Request::create('/int/v1/inspection-forms/x', 'PUT', ['inspection_form' => ['draft' => [['name' => 'Only group', 'fields' => [['label' => 'One', 'type' => 'pass-fail']]]]]]), $form);
+ expect($form->fresh()->fields()->count())->toBe(1)
+ ->and($form->fresh()->fieldGroups()->count())->toBe(1);
+
+ $find = InspectionForm::query();
+ $controller->onFindRecord($find, Request::create('/'));
+ $query = InspectionForm::query();
+ $controller->onQueryRecord($query, Request::create('/'));
+ expect(array_keys($find->getEagerLoads()))->toContain('fieldGroups', 'fields')
+ ->and(array_keys($query->getEagerLoads()))->toContain('fieldGroups', 'fields')
+ ->and($find->first()->relationLoaded('fields'))->toBeTrue();
+});
+
+test('the internal submission controller records answers, item results and an export', function () {
+ fleetOpsInspectionFieldDatabase();
+ $controller = new FleetOpsInspectionSubmissionControllerProbe();
+ $form = fleetOpsInspectionFieldForm();
+ InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft());
+ $fields = $form->fields->keyBy('name');
+ $submission = fleetOpsInspectionFieldSubmission($form);
+
+ $controller->onAfterCreate(Request::create('/int/v1/inspection-submissions', 'POST', ['inspection_submission' => ['custom_field_values' => [
+ ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => false, 'comments' => 'Cracked']],
+ ]]]), $submission, []);
+
+ expect($submission->itemResults->pluck('item_key')->all())->toBe(['mirrors'])
+ ->and($submission->itemResults->first()->comments)->toBe('Cracked')
+ ->and($submission->relationLoaded('files'))->toBeTrue();
+
+ // The flat spelling is read too, and answering again rewrites the row.
+ $controller->onAfterUpdate(Request::create('/int/v1/inspection-submissions/x', 'PUT', ['custom_field_values' => [
+ ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => true]],
+ ]]), $submission, []);
+ expect($submission->fresh()->itemResults->first()->passed)->toBeTrue();
+
+ // A submission against a legacy checklist still posts results directly.
+ $controller->onAfterUpdate(Request::create('/int/v1/inspection-submissions/x', 'PUT', ['inspection_submission' => ['item_results' => [
+ ['item_key' => 'hand-written', 'label' => 'Hand written', 'passed' => false],
+ ]]]), $submission, []);
+ expect($submission->fresh()->itemResults->pluck('item_key')->all())->toBe(['hand-written']);
+
+ $query = InspectionSubmission::query();
+ $controller->onQueryRecord($query, Request::create('/'));
+ expect(array_keys($query->getEagerLoads()))->toContain('form', 'vehicle', 'driver', 'itemResults');
+
+ $response = $controller->export(FleetOpsInspectionExportRequestFake::create('/int/v1/inspection-submissions/export', 'POST', ['format' => 'csv', 'selections' => ['a', 'b']]));
+ expect($response['download'])->toMatch('/^inspections-[0-9-]+\.csv$/')
+ ->and($response['headings'])->toContain('Failed Items')
+ ->and($controller->downloads[0][0])->toBeInstanceOf(InspectionExport::class);
+
+ // No format and no selection: the whole sheet, as a workbook.
+ expect($controller->export(FleetOpsInspectionExportRequestFake::create('/int/v1/inspection-submissions/export', 'POST'))['download'])->toEndWith('.xlsx');
+});
+
+test('the submitter takes the body the driver app builds, whole', function () {
+ fleetOpsInspectionFieldDatabase();
+ Carbon::setTestNow('2026-09-10 07:30:00');
+ $form = fleetOpsInspectionFieldForm();
+ InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft());
+ $fields = $form->fields->keyBy('name');
+ $photo = fleetOpsInspectionFieldPhoto();
+
+ // Exactly what `buildSubmission` in the app's useInspections.ts emits:
+ // both bodies, every pass-fail answer carrying `not_applicable` and
+ // `unsafe`, photos and the signature as bare base64.
+ $submission = InspectionSubmitter::submit($form, [
+ 'odometer' => 112480,
+ 'engine_hours' => null,
+ 'custom_field_values' => [
+ ['custom_field' => $fields['brakes']->uuid, 'value_type' => 'object', 'value' => ['passed' => false, 'not_applicable' => false, 'severity' => 'critical', 'comments' => 'Soft pedal', 'photos' => [$photo], 'unsafe' => true]],
+ ['custom_field' => $fields['mirrors']->uuid, 'value_type' => 'object', 'value' => ['passed' => true, 'not_applicable' => true, 'severity' => null, 'comments' => null, 'photos' => [], 'unsafe' => false]],
+ ['custom_field' => $fields['odometer']->uuid, 'value_type' => 'number', 'value' => 112480],
+ ['custom_field' => $fields['signature']->uuid, 'value_type' => 'file', 'value' => $photo],
+ ['custom_field' => $fields['notes']->uuid, 'value_type' => 'text', 'value' => 'Nearside mirror scuffed'],
+ ],
+ 'item_results' => [
+ ['item_key' => 'brakes', 'label' => 'Brakes', 'category' => 'Exterior', 'status' => 'failed', 'severity' => 'critical', 'passed' => false, 'comments' => 'Soft pedal', 'photos' => [$photo]],
+ ['item_key' => 'mirrors', 'label' => 'Mirrors', 'category' => 'Exterior', 'status' => 'not_applicable', 'severity' => null, 'passed' => true, 'comments' => null, 'photos' => []],
+ ],
+ 'location' => ['latitude' => 1.3521, 'longitude' => 103.8198],
+ 'signature' => ['image' => $photo, 'signed_at' => '2026-09-10T07:30:00Z'],
+ 'meta' => ['source_app' => 'navigator', 'unsafe' => true],
+ ], [
+ 'driver_uuid' => 'driver-1',
+ 'vehicle_uuid' => 'vehicle-1',
+ 'submitted_by_uuid' => 'user-driver',
+ 'source' => 'navigator',
+ ]);
+
+ $submission = $submission->fresh(['itemResults', 'customFieldValues.customField', 'files']);
+ $results = $submission->itemResults->keyBy('item_key');
+
+ expect($submission->customFieldValues)->toHaveCount(5)
+ // The field values won; the duplicated `item_results` were ignored,
+ // so there is one row per pass-fail field and no more.
+ ->and($submission->itemResults)->toHaveCount(2)
+ ->and($results['brakes']->status)->toBe('failed')
+ ->and($results['brakes']->photos[0])->toStartWith('file:')
+ ->and($results['mirrors']->status)->toBe('not_applicable')
+ ->and($results['mirrors']->passed)->toBeTrue()
+ ->and($submission->total_items)->toBe(2)
+ ->and($submission->failed_items)->toBe(1)
+ ->and($submission->result)->toBe('failed')
+ ->and($submission->meta['unsafe'])->toBeTrue()
+ ->and($submission->odometer)->toBe(112480)
+ ->and($submission->files)->toHaveCount(2)
+ ->and($submission->source)->toBe('navigator');
+});
+
+test('the submitter takes field answers straight from a submit body', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm(['settings' => ['create_issue_on_failure' => true, 'create_work_order_on_failure' => true]]);
+ InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft());
+ $fields = $form->fields->keyBy('name');
+
+ $submission = InspectionSubmitter::submit($form, [
+ 'odometer' => 112480,
+ 'custom_field_values' => [
+ ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => false, 'comments' => 'Cracked', 'severity' => 'high']],
+ ],
+ // Sent alongside by the app; the field values win and these are ignored.
+ 'item_results' => [['item_key' => 'mirrors', 'label' => 'Mirrors', 'passed' => false]],
+ ], ['driver_uuid' => 'driver-1', 'vehicle_uuid' => 'vehicle-1', 'submitted_by_uuid' => 'user-driver']);
+
+ expect($submission->itemResults()->count())->toBe(1)
+ ->and($submission->itemResults()->first()->severity)->toBe('high')
+ ->and($submission->fresh()->result)->toBe('failed')
+ ->and($submission->fresh()->issue_uuid)->not->toBeNull()
+ ->and($submission->fresh()->work_order_uuid)->not->toBeNull();
+});
+
+test('a photo that cannot be stored is left as it arrived', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm();
+ $submission = fleetOpsInspectionFieldSubmission($form);
+
+ // A disk that cannot be written to: the store gives back what it was given
+ // rather than losing the answer along with the photo.
+ $readOnly = sys_get_temp_dir() . '/fleetops-inspection-readonly-' . bin2hex(random_bytes(6));
+ $folder = $readOnly . '/inspections/' . $submission->uuid;
+ if (!is_dir($folder)) {
+ mkdir($folder, 0755, true);
+ }
+ chmod($folder, 0555);
+ app('config')->set('filesystems.disks.broken', ['driver' => 'local', 'root' => $readOnly, 'throw' => false]);
+ app('config')->set('filesystems.default', 'broken');
+
+ $photo = fleetOpsInspectionFieldPhoto();
+ $left = InspectionFileStore::normalize($photo, $submission);
+ chmod($folder, 0755);
+
+ expect($left)->toBe($photo)
+ ->and(File::query()->count())->toBe(0);
+});
+
+test('the file store keeps only references a submission may use', function () {
+ fleetOpsInspectionFieldDatabase();
+ $form = fleetOpsInspectionFieldForm();
+
+ // Another company's file, not yet attached to anything.
+ $foreign = File::create(['company_uuid' => 'company-other', 'disk' => 'uploads', 'path' => 'foreign.png', 'type' => 'inspection_photo']);
+ // This company's file, uploaded through a public link.
+ $viaLink = File::create(['company_uuid' => 'company-insp', 'disk' => 'uploads', 'path' => 'link.png', 'type' => 'inspection_photo', 'meta' => ['inspection_link_uuid' => 'link-one']]);
+ // This company's file, uploaded in the console.
+ $console = File::create(['company_uuid' => 'company-insp', 'disk' => 'uploads', 'path' => 'console.png', 'type' => 'inspection_photo']);
+
+ // Through the console or the app: the company's own files, and nothing
+ // else. Another company's file is neither kept nor claimed, whether it is
+ // named by uuid or by public id.
+ $consoleSubmission = fleetOpsInspectionFieldSubmission($form);
+ expect(InspectionFileStore::normalize('file:' . $foreign->uuid, $consoleSubmission))->toBeNull()
+ ->and(InspectionFileStore::normalize($foreign->public_id, $consoleSubmission))->toBeNull()
+ ->and(InspectionFileStore::normalize('file:' . $console->public_id, $consoleSubmission))->toBe('file:' . $console->uuid)
+ ->and(InspectionFileStore::attachReferenced($consoleSubmission, [$foreign->uuid]))->toBe(0)
+ ->and($foreign->fresh()->subject_uuid)->toBeNull();
+
+ // Through a public link: only files uploaded through that same link.
+ $linked = fleetOpsInspectionFieldSubmission($form, ['source' => 'public_link', 'meta' => ['inspection_link_uuid' => 'link-one']]);
+ expect(InspectionFileStore::normalize('file:' . $viaLink->public_id, $linked))->toBe('file:' . $viaLink->uuid)
+ ->and(InspectionFileStore::attachReferenced($linked, [$console->uuid, $viaLink->uuid]))->toBe(1)
+ ->and($viaLink->fresh()->subject_uuid)->toBe($linked->uuid)
+ ->and($console->fresh()->subject_uuid)->toBeNull();
+
+ // A link submission naming any other file, or an outside URL, is refused
+ // rather than silently losing a photo it named.
+ expect(fn () => InspectionFileStore::normalize('file:' . $console->uuid, $linked))->toThrow(ValidationException::class)
+ ->and(fn () => InspectionFileStore::normalize('https://cdn.example.com/a.jpg', $linked))->toThrow(ValidationException::class);
+});
+
+test('groups and fields sort the way the builder laid them out', function () {
+ $ordered = new Category(['name' => 'first']);
+ $unordered = new Category(['name' => 'later']);
+ $older = new Category(['name' => 'older']);
+ $ordered->order = 2;
+ $older->created_at = '2026-01-01 00:00:00';
+ $unordered->created_at = '2026-06-01 00:00:00';
+
+ expect(InspectionForm::sortByOrder(collect([$unordered, $ordered, $older]))->pluck('name')->all())
+ ->toBe(['first', 'older', 'later'])
+ ->and(InspectionForm::sortByOrder(collect([$ordered, $unordered]))->pluck('name')->all())
+ ->toBe(['first', 'later'])
+ ->and(InspectionForm::sortByOrder(collect([$older, $unordered]))->pluck('name')->all())
+ ->toBe(['older', 'later']);
+});
diff --git a/server/tests/InspectionModelContractsTest.php b/server/tests/InspectionModelContractsTest.php
new file mode 100644
index 000000000..d43d69262
--- /dev/null
+++ b/server/tests/InspectionModelContractsTest.php
@@ -0,0 +1,725 @@
+sqliteCreateFunction('ST_PointFromText', $asStoredPoint);
+ $pdo->sqliteCreateFunction('ST_GeomFromText', $asStoredPoint);
+ $connection = new SQLiteConnection($pdo);
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ EloquentModel::setConnectionResolver($resolver);
+ EloquentModel::setEventDispatcher(new Dispatcher());
+ EloquentModel::clearBootedModels();
+
+ $config = new Repository([
+ 'activitylog' => ['enabled' => false, 'default_auth_driver' => null, 'default_log_name' => 'default'],
+ 'api' => ['cache' => ['enabled' => false]],
+ 'filesystems' => ['default' => 'local'],
+ ]);
+ app()->instance('config', $config);
+ app()->instance(Illuminate\Contracts\Config\Repository::class, $config);
+ app()->instance(Spatie\Activitylog\CauserResolver::class, new class extends Spatie\Activitylog\CauserResolver {
+ public function __construct()
+ {
+ }
+
+ public function resolve(EloquentModel|int|string|null $subject = null): ?EloquentModel
+ {
+ return null;
+ }
+ });
+ app()->instance('db', new class($connection) {
+ public function __construct(public SQLiteConnection $connection)
+ {
+ }
+
+ public function connection($name = null): SQLiteConnection
+ {
+ return $this->connection;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return $this->connection->{$method}(...$arguments);
+ }
+ });
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+ app()->instance('db.schema', $connection->getSchemaBuilder());
+ // The link's token and PIN are stored with the `encrypted` cast, which
+ // resolves the container's encrypter. A reversible stand-in is enough to
+ // show a value goes in encrypted and comes back as it was.
+ $encrypter = new class {
+ // Eloquent's `encrypted` cast calls encrypt($value, false) and
+ // decrypt($value, false); the string variants are here for anything
+ // that goes through Crypt::encryptString() instead.
+ public function encrypt($value, $serialize = true)
+ {
+ return 'enc:' . base64_encode($serialize ? serialize($value) : (string) $value);
+ }
+
+ public function decrypt($value, $unserialize = true)
+ {
+ if (!is_string($value) || !str_starts_with($value, 'enc:')) {
+ throw new RuntimeException('Unable to decrypt.');
+ }
+
+ $decoded = base64_decode(substr($value, 4), true);
+
+ return $unserialize ? unserialize($decoded) : $decoded;
+ }
+
+ public function encryptString($value)
+ {
+ return $this->encrypt($value, false);
+ }
+
+ public function decryptString($value)
+ {
+ return $this->decrypt($value, false);
+ }
+ };
+ app()->instance('encrypter', $encrypter);
+ Illuminate\Support\Facades\Crypt::clearResolvedInstance('encrypter');
+ EloquentModel::encryptUsing($encrypter);
+ app()->instance('responsecache', new class {
+ public function __call($method, $arguments)
+ {
+ return null;
+ }
+ });
+ app()->instance('request', Request::create('/'));
+
+ $schema = $connection->getSchemaBuilder();
+ $tables = [
+ 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'],
+ 'inspection_links' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'driver_uuid', 'vehicle_uuid', 'assignee_uuid', 'created_by_uuid', 'token_hash', 'token', 'pin_hash', 'pin', 'pin_attempts', 'pin_sent_via', 'pin_sent_at', 'status', 'single_use', 'expires_at', 'last_viewed_at', 'used_at', 'used_ip', 'used_user_agent', 'meta'],
+ 'inspection_submissions' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'vehicle_uuid', 'driver_uuid', 'submitted_by_uuid', 'issue_uuid', 'work_order_uuid', 'type', 'status', 'result', 'source', 'odometer', 'engine_hours', 'total_items', 'failed_items', 'started_at', 'submitted_at', 'resolved_at', 'location', 'signature', 'attachments', 'meta', 'created_by_uuid', 'updated_by_uuid'],
+ 'inspection_item_results' => ['uuid', '_key', 'company_uuid', 'inspection_submission_uuid', 'issue_uuid', 'work_order_uuid', 'item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos', 'meta', 'created_by_uuid', 'updated_by_uuid'],
+ 'issues' => ['uuid', 'public_id', '_key', 'company_uuid', 'reported_by_uuid', 'assigned_to_uuid', 'vehicle_uuid', 'driver_uuid', 'order_uuid', 'issue_id', 'location', 'category', 'type', 'report', 'title', 'tags', 'priority', 'meta', 'resolved_at', 'status'],
+ 'work_orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'schedule_uuid', 'code', 'subject', 'category', 'status', 'priority', 'target_type', 'target_uuid', 'assignee_type', 'assignee_uuid', 'opened_at', 'due_at', 'closed_at', 'instructions', 'checklist', 'currency', 'estimated_cost', 'approved_budget', 'actual_cost', 'cost_center', 'budget_code', 'meta', 'created_by_uuid', 'updated_by_uuid'],
+ 'vehicles' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'vendor_uuid', 'photo_uuid', 'name', 'make', 'model', 'year', 'trim', 'plate_number', 'vin', 'status', 'currency', 'slug', 'online', 'location'],
+ 'drivers' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'current_job_uuid', 'photo_uuid', 'status', 'online', 'location', 'slug'],
+ 'users' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'email', 'phone', 'avatar_uuid', 'type', 'status'],
+ 'companies' => ['uuid', 'public_id', '_key', 'name', 'owner_uuid'],
+ 'company_users' => ['uuid', '_key', 'company_uuid', 'user_uuid', 'role_uuid', 'status'],
+ 'settings' => ['key', 'value'],
+ 'files' => ['uuid', 'public_id', '_key', 'company_uuid', 'uploader_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'bucket', 'folder', 'etag', 'meta', 'original_filename', 'type', 'content_type', 'file_size', 'slug', 'caption'],
+ 'vendors' => ['uuid', 'public_id', '_key', 'company_uuid', 'name'],
+ 'orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'driver_assigned_uuid', 'status'],
+ 'positions' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_uuid', 'subject_type', 'coordinates'],
+ 'maintenances' => ['uuid', 'public_id', '_key', 'company_uuid', 'maintainable_type', 'maintainable_uuid', 'status', 'completed_at'],
+ 'maintenance_schedules' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_type', 'subject_uuid', 'status', 'next_due_at'],
+ 'custom_field_values' => ['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'],
+ 'custom_fields' => ['uuid', 'company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order'],
+ 'categories' => ['uuid', 'public_id', '_key', 'company_uuid', 'owner_uuid', 'owner_type', 'parent_uuid', 'icon_file_uuid', 'internal_id', 'name', 'description', 'translations', 'meta', 'tags', 'icon', 'icon_color', 'slug', 'order', 'for', 'core_category'],
+ 'activity_log' => ['uuid', 'company_uuid', 'log_name', 'description', 'subject_type', 'subject_id', 'causer_type', 'causer_id', 'properties', 'event', 'batch_uuid'],
+ ];
+
+ foreach ($tables as $table => $columns) {
+ $schema->create($table, function ($blueprint) use ($columns) {
+ $blueprint->increments('id');
+ foreach ($columns as $column) {
+ $blueprint->string($column)->nullable();
+ }
+ $blueprint->timestamps();
+ $blueprint->timestamp('deleted_at')->nullable();
+ });
+ }
+
+ $connection->table('companies')->insert(['uuid' => 'company-insp', 'public_id' => 'company_insp', 'name' => 'Inspection Co']);
+ $connection->table('users')->insert(['uuid' => 'user-driver', 'public_id' => 'user_driver', 'company_uuid' => 'company-insp', 'name' => 'Dana Driver', 'email' => 'dana@example.com', 'phone' => '+15550001111', 'type' => 'user']);
+ $connection->table('users')->insert(['uuid' => 'user-admin', 'public_id' => 'user_admin', 'company_uuid' => 'company-insp', 'name' => 'Avery Admin', 'email' => 'avery@example.com', 'type' => 'user']);
+ $connection->table('vehicles')->insert(['uuid' => 'vehicle-1', 'public_id' => 'vehicle_one', 'company_uuid' => 'company-insp', 'name' => 'Truck 7', 'plate_number' => 'TRK-7', 'currency' => 'SGD']);
+ $connection->table('vehicles')->insert(['uuid' => 'vehicle-2', 'public_id' => 'vehicle_two', 'company_uuid' => 'company-insp', 'make' => 'Isuzu', 'model' => 'NPR', 'year' => '2022', 'plate_number' => 'TRK-2']);
+ $connection->table('drivers')->insert([
+ ['uuid' => 'driver-1', 'public_id' => 'driver_one', 'company_uuid' => 'company-insp', 'user_uuid' => 'user-driver', 'vehicle_uuid' => 'vehicle-1', 'status' => 'active'],
+ ]);
+
+ session(['company' => 'company-insp', 'user' => 'user-admin']);
+
+ return $connection;
+}
+
+function fleetOpsInspectionModelForm(array $attributes = []): InspectionForm
+{
+ return InspectionForm::create(array_merge([
+ 'company_uuid' => 'company-insp',
+ 'name' => 'Pre-trip DVIR',
+ 'type' => 'dvir',
+ 'status' => 'draft',
+ 'items' => [
+ ['key' => 'brakes', 'label' => 'Brakes', 'category' => 'Safety', 'severity' => 'critical'],
+ ['key' => 'lights', 'label' => 'Lights', 'category' => 'Safety', 'severity' => 'medium'],
+ ],
+ 'settings' => ['create_issue_on_failure' => true, 'create_work_order_on_failure' => true],
+ 'created_by_uuid' => 'user-admin',
+ 'updated_by_uuid' => 'user-admin',
+ ], $attributes));
+}
+
+function fleetOpsInspectionModelSubmission(InspectionForm $form, array $items, array $attributes = []): InspectionSubmission
+{
+ $submission = InspectionSubmission::create(array_merge([
+ 'company_uuid' => 'company-insp',
+ 'inspection_form_uuid' => $form->uuid,
+ 'vehicle_uuid' => 'vehicle-1',
+ 'driver_uuid' => 'driver-1',
+ 'submitted_by_uuid' => 'user-driver',
+ 'type' => 'dvir',
+ 'status' => 'draft',
+ 'source' => 'test',
+ ], $attributes));
+
+ foreach ($items as $item) {
+ InspectionItemResult::create(array_merge([
+ 'company_uuid' => 'company-insp',
+ 'inspection_submission_uuid' => $submission->uuid,
+ ], $item));
+ }
+
+ return $submission;
+}
+
+afterEach(function () {
+ Carbon::setTestNow();
+});
+
+test('inspection form knows when it is published and can be archived', function () {
+ fleetOpsInspectionModelDatabase();
+
+ $form = fleetOpsInspectionModelForm();
+
+ // A draft is not published, and neither is a form whose status says so
+ // without the timestamp to prove it.
+ expect($form->is_published)->toBeFalse()
+ ->and($form->item_count)->toBe(2)
+ ->and($form->public_id)->toStartWith('inspection_form_')
+ ->and($form->getActivitylogOptions())->toBeInstanceOf(LogOptions::class);
+
+ $form->update(['status' => 'published']);
+ expect($form->fresh()->is_published)->toBeFalse();
+
+ Carbon::setTestNow('2026-09-09 08:00:00');
+ expect($form->publish())->toBeTrue();
+
+ $published = $form->fresh();
+ expect($published->is_published)->toBeTrue()
+ ->and($published->published_at->toDateTimeString())->toBe('2026-09-09 08:00:00');
+
+ // Publishing again keeps the original publication date.
+ Carbon::setTestNow('2026-09-10 08:00:00');
+ $published->publish();
+ expect($published->fresh()->published_at->toDateTimeString())->toBe('2026-09-09 08:00:00');
+
+ expect($published->archive())->toBeTrue()
+ ->and($published->fresh()->status)->toBe('archived')
+ ->and($published->fresh()->is_published)->toBeFalse();
+});
+
+test('inspection form names its subject and exposes its relations', function () {
+ fleetOpsInspectionModelDatabase();
+
+ $bound = fleetOpsInspectionModelForm(['subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-1']);
+ $wide = fleetOpsInspectionModelForm(['name' => 'Fleet-wide']);
+
+ expect($bound->fresh()->subject_name)->toBe('Truck 7')
+ ->and($wide->fresh()->subject_name)->toBeNull()
+ ->and($bound->subject())->toBeInstanceOf(MorphTo::class)
+ ->and($bound->submissions())->toBeInstanceOf(HasMany::class)
+ ->and($bound->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and($bound->updatedBy())->toBeInstanceOf(BelongsTo::class)
+ ->and($bound->fresh()->createdBy->name)->toBe('Avery Admin');
+
+ $submission = fleetOpsInspectionModelSubmission($bound, []);
+ expect($bound->submissions()->count())->toBe(1)
+ ->and($bound->submissions->first()->uuid)->toBe($submission->uuid);
+});
+
+test('inspection submission counts its results and names what it belongs to', function () {
+ fleetOpsInspectionModelDatabase();
+
+ $form = fleetOpsInspectionModelForm();
+ $submission = fleetOpsInspectionModelSubmission($form, [
+ ['label' => 'Brakes', 'passed' => true],
+ ['label' => 'Lights', 'passed' => false, 'severity' => 'medium'],
+ ['label' => 'Horn', 'passed' => false],
+ ]);
+
+ expect($submission->form())->toBeInstanceOf(BelongsTo::class)
+ ->and($submission->vehicle())->toBeInstanceOf(BelongsTo::class)
+ ->and($submission->driver())->toBeInstanceOf(BelongsTo::class)
+ ->and($submission->submittedBy())->toBeInstanceOf(BelongsTo::class)
+ ->and($submission->issue())->toBeInstanceOf(BelongsTo::class)
+ ->and($submission->workOrder())->toBeInstanceOf(BelongsTo::class)
+ ->and($submission->itemResults())->toBeInstanceOf(HasMany::class)
+ ->and($submission->failedItemResults())->toBeInstanceOf(HasMany::class)
+ ->and($submission->getActivitylogOptions())->toBeInstanceOf(LogOptions::class)
+ ->and($submission->public_id)->toStartWith('inspection_submission_');
+
+ // Nothing counted yet: a fresh draft has no failures on record.
+ expect($submission->has_failures)->toBeFalse();
+
+ Carbon::setTestNow('2026-09-09 09:30:00');
+ expect($submission->syncResultCounts())->toBeTrue();
+
+ $synced = $submission->fresh();
+ expect($synced->total_items)->toBe(3)
+ ->and($synced->failed_items)->toBe(2)
+ ->and($synced->result)->toBe('failed')
+ ->and($synced->status)->toBe('submitted')
+ ->and($synced->submitted_at->toDateTimeString())->toBe('2026-09-09 09:30:00')
+ ->and($synced->has_failures)->toBeTrue()
+ ->and($synced->form_name)->toBe('Pre-trip DVIR')
+ ->and($synced->vehicle_name)->toBe('Truck 7')
+ ->and($synced->driver_name)->toBe('Dana Driver')
+ ->and($synced->submittedBy->name)->toBe('Dana Driver');
+
+ // A second sync keeps the first submitted_at and leaves a non-draft status alone.
+ $synced->update(['status' => 'needs_review']);
+ Carbon::setTestNow('2026-09-09 11:00:00');
+ $synced->syncResultCounts();
+ expect($synced->fresh()->status)->toBe('needs_review')
+ ->and($synced->fresh()->submitted_at->toDateTimeString())->toBe('2026-09-09 09:30:00');
+
+ // `result` alone is enough to count as failed, for rows written before counts existed.
+ $legacy = fleetOpsInspectionModelSubmission($form, [], ['result' => 'failed', 'failed_items' => 0]);
+ expect($legacy->has_failures)->toBeTrue();
+
+ // A vehicle with no name is named from its make and model, and a display
+ // name is what the submission reports.
+ $unnamed = fleetOpsInspectionModelSubmission($form, [], ['vehicle_uuid' => 'vehicle-2']);
+ expect($unnamed->fresh()->vehicle_name)->toBe('2022 Isuzu NPR');
+});
+
+test('inspection submission falls back to its column defaults when sent null', function () {
+ fleetOpsInspectionModelDatabase();
+
+ $form = fleetOpsInspectionModelForm();
+
+ // The console's model sends every attribute, nulls included; an explicit
+ // null would override the column default and the insert would be refused.
+ $submission = InspectionSubmission::create([
+ 'company_uuid' => 'company-insp',
+ 'inspection_form_uuid' => $form->uuid,
+ 'type' => null,
+ 'status' => null,
+ 'total_items' => null,
+ 'failed_items' => null,
+ ])->fresh();
+
+ expect($submission->type)->toBe('dvir')
+ ->and($submission->status)->toBe('draft')
+ ->and($submission->total_items)->toBe(0)
+ ->and($submission->failed_items)->toBe(0);
+});
+
+test('inspection submission ranks failures by severity', function () {
+ fleetOpsInspectionModelDatabase();
+
+ $form = fleetOpsInspectionModelForm();
+
+ $clean = fleetOpsInspectionModelSubmission($form, [['label' => 'Brakes', 'passed' => true]]);
+ $clean->syncResultCounts();
+ expect($clean->fresh()->highestFailureSeverity())->toBe('low');
+
+ // Failed without a severity is still a failure, and defaults high rather
+ // than being quietly filed as low.
+ $unranked = fleetOpsInspectionModelSubmission($form, [['label' => 'Horn', 'passed' => false]]);
+ $unranked->syncResultCounts();
+ expect($unranked->fresh()->highestFailureSeverity())->toBe('high');
+
+ $mixed = fleetOpsInspectionModelSubmission($form, [
+ ['label' => 'Lights', 'passed' => false, 'severity' => 'Medium'],
+ ['label' => 'Mirror', 'passed' => false, 'severity' => 'low'],
+ ['label' => 'Brakes', 'passed' => false, 'severity' => 'CRITICAL'],
+ ['label' => 'Tyres', 'passed' => true, 'severity' => 'critical'],
+ ]);
+ $mixed->syncResultCounts();
+ expect($mixed->fresh()->highestFailureSeverity())->toBe('critical');
+
+ $medium = fleetOpsInspectionModelSubmission($form, [
+ ['label' => 'Lights', 'passed' => false, 'severity' => 'medium'],
+ ['label' => 'Mirror', 'passed' => false, 'severity' => 'low'],
+ ]);
+ $medium->syncResultCounts();
+ expect($medium->fresh()->highestFailureSeverity())->toBe('medium');
+});
+
+test('inspection submission raises an issue from its failed items once', function () {
+ $connection = fleetOpsInspectionModelDatabase();
+
+ $form = fleetOpsInspectionModelForm();
+
+ $clean = fleetOpsInspectionModelSubmission($form, [['label' => 'Brakes', 'passed' => true]]);
+ $clean->syncResultCounts();
+ expect($clean->fresh()->createIssueFromFailures())->toBeNull()
+ ->and(Issue::query()->count())->toBe(0);
+
+ $failed = fleetOpsInspectionModelSubmission($form, [
+ ['label' => 'Brakes', 'passed' => false, 'severity' => 'critical', 'item_key' => 'brakes'],
+ ['label' => 'Lights', 'passed' => false, 'severity' => 'medium', 'item_key' => 'lights'],
+ ['label' => 'Horn', 'passed' => true],
+ ], ['location' => ['latitude' => 1.3521, 'longitude' => 103.8198]]);
+ $failed->syncResultCounts();
+ $failed = $failed->fresh();
+
+ $issue = $failed->createIssueFromFailures();
+
+ expect($issue)->toBeInstanceOf(Issue::class)
+ ->and($issue->title)->toBe('Failed inspection: Truck 7')
+ ->and($issue->report)->toBe('Failed items: Brakes, Lights')
+ ->and($issue->priority)->toBe('critical')
+ ->and($issue->type)->toBe('inspection')
+ ->and($issue->category)->toBe('inspection_failed')
+ ->and($issue->status)->toBe('pending')
+ ->and($issue->reported_by_uuid)->toBe('user-driver')
+ ->and($issue->vehicle_uuid)->toBe('vehicle-1')
+ ->and($issue->driver_uuid)->toBe('driver-1')
+ // `issues.location` has no default: an issue raised without one is
+ // refused by the database, so it takes where the inspection was filed.
+ ->and($issue->location)->toBeInstanceOf(Point::class)
+ ->and($issue->location->getLat())->toEqual(1.3521)
+ ->and($issue->location->getLng())->toEqual(103.8198)
+ ->and($issue->meta['inspection_submission_uuid'])->toBe($failed->uuid)
+ ->and($issue->meta['failed_items'])->toBe(['Brakes', 'Lights'])
+ ->and($failed->fresh()->issue_uuid)->toBe($issue->uuid);
+
+ // Asking again hands back the same issue rather than filing a duplicate.
+ $again = $failed->fresh()->createIssueFromFailures();
+ expect($again->uuid)->toBe($issue->uuid)
+ ->and(Issue::query()->count())->toBe(1);
+
+ // No vehicle to name: the submission's own id stands in.
+ $unassigned = fleetOpsInspectionModelSubmission($form, [['label' => 'Horn', 'passed' => false]], ['vehicle_uuid' => null]);
+ $unassigned->syncResultCounts();
+ $orphanIssue = $unassigned->fresh()->createIssueFromFailures();
+ expect($orphanIssue->title)->toBe('Failed inspection: ' . $unassigned->public_id)
+ ->and($orphanIssue->report)->toBe('Failed items: Horn')
+ // Nowhere to take a position from: an empty point, which the column takes.
+ ->and($orphanIssue->location)->toBeInstanceOf(Point::class)
+ ->and($orphanIssue->location->getLat())->toEqual(0.0)
+ ->and($orphanIssue->location->getLng())->toEqual(0.0);
+
+ // The vehicle's last known position stands in when the submission has none,
+ // stored the way MySQL keeps it so the model reads it back as a point.
+ $connection->table('vehicles')->where('uuid', 'vehicle-1')->update([
+ 'location' => pack('V', 0) . pack('C', 1) . pack('V', 1) . pack('d', 103.9915) . pack('d', 1.3644),
+ ]);
+
+ $atVehicle = fleetOpsInspectionModelSubmission($form, [['label' => 'Horn', 'passed' => false]]);
+ $atVehicle->syncResultCounts();
+ $atVehicleIssue = $atVehicle->fresh()->createIssueFromFailures();
+
+ expect($atVehicleIssue->location)->toBeInstanceOf(Point::class)
+ ->and(round($atVehicleIssue->location->getLat(), 4))->toEqual(1.3644)
+ ->and(round($atVehicleIssue->location->getLng(), 4))->toEqual(103.9915);
+});
+
+test('inspection submission opens a work order with a checklist of the failed items', function () {
+ fleetOpsInspectionModelDatabase();
+ Carbon::setTestNow('2026-09-09 10:00:00');
+
+ $form = fleetOpsInspectionModelForm();
+
+ $clean = fleetOpsInspectionModelSubmission($form, [['label' => 'Brakes', 'passed' => true]]);
+ $clean->syncResultCounts();
+ expect($clean->fresh()->createWorkOrderFromFailures())->toBeNull()
+ ->and(WorkOrder::query()->count())->toBe(0);
+
+ $critical = fleetOpsInspectionModelSubmission($form, [
+ ['label' => 'Brakes', 'passed' => false, 'severity' => 'critical', 'item_key' => 'brakes'],
+ ['label' => 'Horn', 'passed' => true],
+ ]);
+ $critical->syncResultCounts();
+ $critical->fresh()->createIssueFromFailures();
+ $critical = $critical->fresh();
+
+ $workOrder = $critical->createWorkOrderFromFailures();
+
+ expect($workOrder)->toBeInstanceOf(WorkOrder::class)
+ ->and($workOrder->subject)->toBe('Inspection repair: Truck 7')
+ ->and($workOrder->status)->toBe('open')
+ ->and($workOrder->priority)->toBe('critical')
+ ->and($workOrder->target_type)->toBe(Vehicle::class)
+ ->and($workOrder->target_uuid)->toBe('vehicle-1')
+ ->and($workOrder->currency)->toBe('SGD')
+ ->and($workOrder->created_by_uuid)->toBe('user-driver')
+ // Critical is due tomorrow; anything else gets a week.
+ ->and($workOrder->due_at->toDateTimeString())->toBe('2026-09-10 10:00:00')
+ ->and($workOrder->checklist)->toHaveCount(1)
+ ->and($workOrder->checklist[0])->toMatchArray(['title' => 'Brakes', 'item_key' => 'brakes', 'severity' => 'critical', 'required' => true, 'completed' => false, 'source' => 'inspection'])
+ ->and($workOrder->meta['issue_uuid'])->toBe($critical->issue_uuid)
+ ->and($critical->fresh()->work_order_uuid)->toBe($workOrder->uuid);
+
+ // The failed items now point at the work order; the passed one does not.
+ expect($critical->itemResults()->where('label', 'Brakes')->value('work_order_uuid'))->toBe($workOrder->uuid)
+ ->and($critical->itemResults()->where('label', 'Horn')->value('work_order_uuid'))->toBeNull();
+
+ $again = $critical->fresh()->createWorkOrderFromFailures();
+ expect($again->uuid)->toBe($workOrder->uuid)
+ ->and(WorkOrder::query()->count())->toBe(1);
+
+ $routine = fleetOpsInspectionModelSubmission($form, [['label' => 'Lights', 'passed' => false, 'severity' => 'medium']], ['vehicle_uuid' => null]);
+ $routine->syncResultCounts();
+ $routineOrder = $routine->fresh()->createWorkOrderFromFailures();
+ expect($routineOrder->due_at->toDateTimeString())->toBe('2026-09-16 10:00:00')
+ ->and($routineOrder->target_type)->toBeNull()
+ ->and($routineOrder->subject)->toBe('Inspection repair: ' . $routine->public_id);
+});
+
+test('inspection item result belongs to its submission and follow-up', function () {
+ fleetOpsInspectionModelDatabase();
+
+ $form = fleetOpsInspectionModelForm();
+ $submission = fleetOpsInspectionModelSubmission($form, [
+ ['label' => 'Brakes', 'passed' => false, 'photos' => ['data:image/png;base64,iVBORw0KGgo='], 'meta' => ['note' => 'squeal'], 'created_by_uuid' => 'user-driver'],
+ ]);
+
+ $result = $submission->itemResults()->first();
+
+ expect($result->submission())->toBeInstanceOf(BelongsTo::class)
+ ->and($result->issue())->toBeInstanceOf(BelongsTo::class)
+ ->and($result->workOrder())->toBeInstanceOf(BelongsTo::class)
+ ->and($result->createdBy())->toBeInstanceOf(BelongsTo::class)
+ ->and($result->getActivitylogOptions())->toBeInstanceOf(LogOptions::class)
+ ->and($result->submission_id)->toBe($submission->public_id)
+ ->and($result->passed)->toBeFalse()
+ ->and($result->photos)->toBe(['data:image/png;base64,iVBORw0KGgo='])
+ ->and($result->meta['note'])->toBe('squeal')
+ ->and($result->createdBy->name)->toBe('Dana Driver');
+
+ $orphan = new InspectionItemResult();
+ expect($orphan->submission_id)->toBeNull();
+});
+
+test('inspection link is usable only while active, unexpired and unused', function () {
+ fleetOpsInspectionModelDatabase();
+ Carbon::setTestNow('2026-09-09 12:00:00');
+
+ $token = InspectionLink::generateToken();
+ expect(strlen($token))->toBe(64)
+ ->and(InspectionLink::hashToken($token))->toBe(hash('sha256', $token))
+ ->and(InspectionLink::generateToken())->not->toBe($token);
+
+ $form = fleetOpsInspectionModelForm();
+ $make = fn (array $attributes = []) => InspectionLink::create(array_merge([
+ 'company_uuid' => 'company-insp',
+ 'inspection_form_uuid' => $form->uuid,
+ 'driver_uuid' => 'driver-1',
+ 'vehicle_uuid' => 'vehicle-1',
+ 'created_by_uuid' => 'user-admin',
+ 'token_hash' => InspectionLink::hashToken(InspectionLink::generateToken()),
+ 'status' => 'active',
+ 'single_use' => true,
+ ], $attributes));
+
+ $link = $make();
+ expect($link->isUsable())->toBeTrue()
+ ->and($link->public_id)->toStartWith('inspection_link_')
+ ->and($link->form())->toBeInstanceOf(BelongsTo::class)
+ ->and($link->driver())->toBeInstanceOf(BelongsTo::class)
+ ->and($link->vehicle())->toBeInstanceOf(BelongsTo::class)
+ ->and($link->createdBy())->toBeInstanceOf(BelongsTo::class);
+
+ $loaded = $link->fresh();
+ expect($loaded->form->name)->toBe('Pre-trip DVIR')
+ ->and($loaded->driver->uuid)->toBe('driver-1')
+ ->and($loaded->vehicle->name)->toBe('Truck 7')
+ ->and($loaded->createdBy->name)->toBe('Avery Admin');
+
+ expect($make(['status' => 'revoked'])->isUsable())->toBeFalse()
+ ->and($make(['expires_at' => '2026-09-09 11:59:59'])->isUsable())->toBeFalse()
+ ->and($make(['expires_at' => '2026-09-09 12:00:01'])->isUsable())->toBeTrue()
+ ->and($make(['used_at' => '2026-09-09 11:00:00'])->isUsable())->toBeFalse()
+ // A reusable link stays open after it has been used.
+ ->and($make(['single_use' => false, 'used_at' => '2026-09-09 11:00:00'])->isUsable())->toBeTrue();
+
+ $link->markViewed();
+ expect($link->fresh()->last_viewed_at->toDateTimeString())->toBe('2026-09-09 12:00:00');
+
+ $link->markUsed('203.0.113.9', 'NavigatorApp/3.0');
+ $used = $link->fresh();
+ expect($used->used_at->toDateTimeString())->toBe('2026-09-09 12:00:00')
+ ->and($used->used_ip)->toBe('203.0.113.9')
+ ->and($used->used_user_agent)->toBe('NavigatorApp/3.0')
+ ->and($used->isUsable())->toBeFalse();
+});
+
+test('inspection link asks for its PIN, counts wrong ones, and locks after too many', function () {
+ fleetOpsInspectionModelDatabase();
+
+ $form = fleetOpsInspectionModelForm();
+ $link = InspectionLink::create([
+ 'company_uuid' => 'company-insp',
+ 'inspection_form_uuid' => $form->uuid,
+ 'created_by_uuid' => 'user-admin',
+ 'token_hash' => InspectionLink::hashToken(InspectionLink::generateToken()),
+ 'status' => 'active',
+ 'single_use' => true,
+ ]);
+
+ // A link minted before PINs existed asks for none.
+ expect($link->hasPin())->toBeFalse()
+ ->and($link->verifyPin(null))->toBe('ok');
+
+ $pin = InspectionLink::generatePin();
+ expect($pin)->toMatch('/^\d{6}$/');
+
+ $link->setPin($pin);
+ $link->save();
+ $wrong = $pin === '000000' ? '111111' : '000000';
+
+ expect($link->fresh()->pin)->toBe($pin)
+ ->and($link->verifyPin(''))->toBe('missing')
+ ->and($link->verifyPin($wrong))->toBe('wrong')
+ ->and($link->fresh()->pin_attempts)->toBe(1)
+ ->and($link->pinAttemptsLeft())->toBe(InspectionLink::MAX_PIN_ATTEMPTS - 1)
+ // Spaces and dashes typed with the PIN do not count against it.
+ ->and($link->verifyPin(substr($pin, 0, 3) . ' ' . substr($pin, 3)))->toBe('ok')
+ ->and($link->fresh()->pin_attempts)->toBe(0);
+
+ foreach (range(1, InspectionLink::MAX_PIN_ATTEMPTS - 1) as $attempt) {
+ expect($link->verifyPin($wrong))->toBe('wrong');
+ }
+
+ expect($link->verifyPin($wrong))->toBe('locked')
+ ->and($link->state)->toBe('locked')
+ ->and($link->isUsable())->toBeFalse()
+ // Once locked, even the right PIN is refused.
+ ->and($link->verifyPin($pin))->toBe('locked');
+});
+
+test('inspection submitter records a submission and the follow-up the form asks for', function () {
+ fleetOpsInspectionModelDatabase();
+ Carbon::setTestNow('2026-09-09 07:00:00');
+
+ $rules = InspectionSubmitter::rules();
+ expect($rules['item_results'])->toBe('required_without:custom_field_values|array')
+ ->and($rules['custom_field_values'])->toBe('required_without:item_results|array')
+ ->and($rules['item_results.*.passed'])->toBe('required|boolean')
+ ->and($rules['item_results.*.photos.*'][1])->toBeInstanceOf(Base64OrUrl::class);
+
+ $form = fleetOpsInspectionModelForm();
+
+ $submission = InspectionSubmitter::submit($form, [
+ 'odometer' => 120400,
+ 'engine_hours' => 3100,
+ 'location' => ['latitude' => 1.35, 'longitude' => 103.82],
+ 'signature' => ['data' => 'sig'],
+ 'attachments' => ['file_one'],
+ 'item_results' => [
+ ['item_key' => 'brakes', 'label' => 'Brakes', 'category' => 'Safety', 'passed' => false, 'severity' => 'critical', 'comments' => 'Soft pedal', 'photos' => ['https://cdn.example.com/brakes.jpg']],
+ // No explicit status: derived from `passed`.
+ ['item_key' => 'lights', 'label' => 'Lights', 'passed' => true],
+ ],
+ ], [
+ 'driver_uuid' => 'driver-1',
+ 'vehicle_uuid' => 'vehicle-1',
+ 'submitted_by_uuid' => 'user-driver',
+ 'source' => 'navigator',
+ 'started_at' => Carbon::parse('2026-09-09 06:45:00'),
+ 'meta' => ['idempotency_key' => 'abc'],
+ ]);
+
+ $submission = $submission->fresh(['itemResults', 'issue', 'workOrder']);
+
+ expect($submission->status)->toBe('submitted')
+ ->and($submission->source)->toBe('navigator')
+ ->and($submission->type)->toBe('dvir')
+ ->and($submission->odometer)->toBe(120400)
+ ->and($submission->engine_hours)->toBe(3100)
+ ->and($submission->started_at->toDateTimeString())->toBe('2026-09-09 06:45:00')
+ ->and($submission->submitted_at->toDateTimeString())->toBe('2026-09-09 07:00:00')
+ ->and($submission->location)->toBe(['latitude' => 1.35, 'longitude' => 103.82])
+ ->and($submission->attachments)->toBe(['file_one'])
+ ->and($submission->meta['idempotency_key'])->toBe('abc')
+ ->and($submission->total_items)->toBe(2)
+ ->and($submission->failed_items)->toBe(1)
+ ->and($submission->result)->toBe('failed')
+ ->and($submission->itemResults->pluck('status', 'item_key')->all())->toBe(['brakes' => 'failed', 'lights' => 'passed'])
+ ->and($submission->itemResults->firstWhere('item_key', 'brakes')->photos)->toBe(['https://cdn.example.com/brakes.jpg'])
+ ->and($submission->issue)->toBeInstanceOf(Issue::class)
+ ->and($submission->workOrder)->toBeInstanceOf(WorkOrder::class)
+ ->and($submission->workOrder->meta['issue_uuid'])->toBe($submission->issue->uuid);
+
+ // A form that asks for nothing gets nothing, and a form with no type is a DVIR.
+ $quiet = fleetOpsInspectionModelForm(['settings' => [], 'type' => null]);
+ $clean = InspectionSubmitter::submit($quiet, [
+ 'item_results' => [['label' => 'Brakes', 'passed' => false, 'status' => 'failed']],
+ ]);
+
+ expect($clean->fresh()->type)->toBe('dvir')
+ ->and($clean->fresh()->started_at->toDateTimeString())->toBe('2026-09-09 07:00:00')
+ ->and($clean->fresh()->issue_uuid)->toBeNull()
+ ->and($clean->fresh()->work_order_uuid)->toBeNull()
+ ->and(Issue::query()->count())->toBe(1)
+ ->and(WorkOrder::query()->count())->toBe(1);
+
+ // Issue only, and no failures to raise it from.
+ $issueOnly = fleetOpsInspectionModelForm(['settings' => ['create_issue_on_failure' => true]]);
+ $passed = InspectionSubmitter::submit($issueOnly, ['item_results' => [['label' => 'Brakes', 'passed' => true]]]);
+ expect($passed->fresh()->result)->toBe('passed')
+ ->and(Issue::query()->count())->toBe(1);
+});
+
+test('base64 or url rule accepts what the driver app sends for a photo', function () {
+ $rule = new Base64OrUrl();
+
+ expect($rule->passes('photos.0', 'https://cdn.example.com/photo.jpg'))->toBeTrue()
+ ->and($rule->passes('photos.0', 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAI'))->toBeTrue()
+ ->and($rule->passes('photos.0', "iVBORw0KGgo\nAAAANSUhEUg=="))->toBeTrue()
+ ->and($rule->passes('photos.0', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=='))->toBeTrue()
+ ->and($rule->passes('photos.0', 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='))->toBeTrue()
+ ->and($rule->passes('photos.0', ''))->toBeFalse()
+ ->and($rule->passes('photos.0', ' '))->toBeFalse()
+ ->and($rule->passes('photos.0', ['not', 'a', 'string']))->toBeFalse()
+ ->and($rule->passes('photos.0', 42))->toBeFalse()
+ ->and($rule->passes('photos.0', 'not base64!'))->toBeFalse()
+ ->and($rule->passes('photos.0', 'data:image/png;base64,'))->toBeFalse()
+ ->and($rule->passes('photos.0', 'data:image/png;base64,***'))->toBeFalse()
+ ->and($rule->message())->toContain(':attribute');
+});
diff --git a/server/tests/InspectionSubmitterRulesTest.php b/server/tests/InspectionSubmitterRulesTest.php
new file mode 100644
index 000000000..2fa092a87
--- /dev/null
+++ b/server/tests/InspectionSubmitterRulesTest.php
@@ -0,0 +1,35 @@
+toHaveKeys([
+ 'custom_field_values',
+ 'custom_field_values.*.custom_field',
+ 'custom_field_values.*.custom_field_uuid',
+ 'custom_field_values.*.value',
+ 'custom_field_values.*.value_type',
+ ]);
+
+ // An empty answer is still an answer (it clears the field), so the value
+ // is allowed to be null rather than required.
+ expect($rules['custom_field_values.*.value'])->toBe('nullable')
+ ->and($rules['custom_field_values.*.custom_field_uuid'])->toContain('nullable');
+
+ // Every key of a flat item result has a rule too, so none of them is dropped.
+ foreach (['item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos'] as $key) {
+ expect($rules)->toHaveKey('item_results.*.' . $key);
+ }
+});
diff --git a/server/tests/ObserverContractsTest.php b/server/tests/ObserverContractsTest.php
index ff2a6d8ae..aef25a1de 100644
--- a/server/tests/ObserverContractsTest.php
+++ b/server/tests/ObserverContractsTest.php
@@ -744,7 +744,7 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order
'maintainable_type' => 'fleet-ops:vehicle',
'maintainable_uuid' => 'vehicle-uuid',
'type' => 'scheduled',
- 'status' => 'done',
+ 'status' => 'completed',
'priority' => 'high',
'performed_by_type' => 'fleet-ops:contact',
'performed_by_uuid' => 'assignee-uuid',
diff --git a/server/tests/ReportSchemaContractsTest.php b/server/tests/ReportSchemaContractsTest.php
index 9b8b07561..b1465b71c 100644
--- a/server/tests/ReportSchemaContractsTest.php
+++ b/server/tests/ReportSchemaContractsTest.php
@@ -219,6 +219,9 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name)
'contacts',
'vendors',
'fuel_reports',
+ 'work_orders',
+ 'maintenances',
+ 'inspection_submissions',
]);
$orders = $tables['orders'];
@@ -248,7 +251,18 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name)
->and(fleetOpsReportTableMeta($tables['places'], 'category'))->toBe('Geography')
->and(fleetOpsReportTableMeta($tables['contacts'], 'category'))->toBe('CRM')
->and(fleetOpsReportTableMeta($tables['vendors'], 'category'))->toBe('CRM')
- ->and(fleetOpsReportTableMeta($tables['fuel_reports'], 'category'))->toBe('Operations');
+ ->and(fleetOpsReportTableMeta($tables['fuel_reports'], 'category'))->toBe('Operations')
+ ->and(fleetOpsReportTableMeta($tables['work_orders'], 'category'))->toBe('Maintenance')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['work_orders'], 'vehicle_target')))->toBe('vehicles')
+ ->and(fleetOpsReportColumnAggregate(fleetOpsReportColumn($tables['work_orders'], 'total_actual_cost')))->toBe('sum')
+ ->and(fleetOpsReportTableMeta($tables['maintenances'], 'label'))->toBe('Maintenance History')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['maintenances'], 'work_order')))->toBe('work_orders')
+ ->and(fleetOpsReportTableMeta($tables['inspection_submissions'], 'label'))->toBe('Inspections')
+ ->and(fleetOpsReportTableMeta($tables['inspection_submissions'], 'category'))->toBe('Maintenance')
+ ->and(fleetOpsReportColumnFlag(fleetOpsReportColumn($tables['inspection_submissions'], 'result'), 'aggregatable'))->toBeTrue()
+ ->and(fleetOpsReportColumnAggregate(fleetOpsReportColumn($tables['inspection_submissions'], 'total_failed_items')))->toBe('sum')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['inspection_submissions'], 'inspection_form')))->toBe('inspection_forms')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['inspection_submissions'], 'work_order')))->toBe('work_orders');
});
test('fleetops report schema transformers normalize labels booleans distances and money', function () {
diff --git a/server/tests/RouteRegistrationExecutionTest.php b/server/tests/RouteRegistrationExecutionTest.php
index 35e90c5f2..803c50f7b 100644
--- a/server/tests/RouteRegistrationExecutionTest.php
+++ b/server/tests/RouteRegistrationExecutionTest.php
@@ -1,6 +1,35 @@
recorder->routes[$this->index]['middleware'] = array_merge(
+ $this->recorder->routes[$this->index]['middleware'] ?? [],
+ (array) $middleware,
+ );
+
+ return $this;
+ }
+
+ public function __call(string $method, array $arguments): self
+ {
+ return $this;
+ }
+}
class FleetOpsRouteRecorder
{
@@ -49,47 +78,69 @@ public function group(array|callable $attributes, ?callable $callback = null): s
return $this;
}
- public function get(string $uri, string|array $action): void
+ public function get(string $uri, string|array $action): FleetOpsRecordedRoute
{
- $this->record('GET', $uri, $action);
+ return $this->record('GET', $uri, $action);
}
- public function post(string $uri, string|array $action): void
+ public function post(string $uri, string|array $action): FleetOpsRecordedRoute
{
- $this->record('POST', $uri, $action);
+ return $this->record('POST', $uri, $action);
}
- public function put(string $uri, string|array $action): void
+ public function put(string $uri, string|array $action): FleetOpsRecordedRoute
{
- $this->record('PUT', $uri, $action);
+ return $this->record('PUT', $uri, $action);
}
- public function patch(string $uri, string|array $action): void
+ public function patch(string $uri, string|array $action): FleetOpsRecordedRoute
{
- $this->record('PATCH', $uri, $action);
+ return $this->record('PATCH', $uri, $action);
}
- public function delete(string $uri, string|array $action): void
+ public function delete(string $uri, string|array $action): FleetOpsRecordedRoute
{
- $this->record('DELETE', $uri, $action);
+ return $this->record('DELETE', $uri, $action);
}
- public function any(string $uri, string|array $action): void
+ public function any(string $uri, string|array $action): FleetOpsRecordedRoute
{
- $this->record('ANY', $uri, $action);
+ return $this->record('ANY', $uri, $action);
}
- public function match(array $methods, string $uri, string|array $action): void
+ public function match(array $methods, string $uri, string|array $action): FleetOpsRecordedRoute
{
- $this->record(implode('|', array_map('strtoupper', $methods)), $uri, $action);
+ return $this->record(implode('|', array_map('strtoupper', $methods)), $uri, $action);
}
- public function fleetbaseRoutes(string $resource): void
+ /**
+ * The platform's resource routes, and the extra routes a resource declares
+ * in its callback. The callback is run inside a group prefixed with the
+ * resource, as the platform's macro does, and handed a `$controller` that
+ * names the action the way the macro would, so those routes are recorded
+ * rather than silently skipped.
+ */
+ public function fleetbaseRoutes(string $resource, ?callable $callback = null): FleetOpsRecordedRoute
{
- $this->record('FLEETBASE', $resource, 'fleetbaseRoutes');
+ $route = $this->record('FLEETBASE', $resource, 'fleetbaseRoutes');
+
+ if ($callback) {
+ $controllerName = Str::studly(Str::singular($resource)) . 'Controller';
+ $this->group(['prefix' => $resource], function ($router) use ($callback, $controllerName) {
+ $callback($router, fn (string $method) => $controllerName . '@' . $method);
+ });
+ }
+
+ return $route;
+ }
+
+ /** Router methods the recorder does not model are accepted and ignored. */
+ public function __call(string $method, array $arguments): self
+ {
+ return $this;
}
- private function record(string $method, string $uri, string|array $action): void
+ private function record(string $method, string $uri, string|array $action): FleetOpsRecordedRoute
{
$prefixes = array_values(array_filter(array_map(
fn (array $group) => $group['prefix'] ?? null,
@@ -102,6 +153,8 @@ private function record(string $method, string $uri, string|array $action): void
'action' => $action,
'groups' => $this->stack,
];
+
+ return new FleetOpsRecordedRoute($this, array_key_last($this->routes));
}
}
@@ -139,7 +192,37 @@ function fleetOpsRecordedRoutes(): FleetOpsRouteRecorder
->toContain('v1/fuel-transactions/{id}/match-vehicle')
->toContain('int/v1/fleet-ops/analytics/operations-pulse')
->toContain('int/v1/fleet-ops/metrics/{slug}')
- ->toContain('int/v1/fleet-ops/hubs/resources');
+ ->toContain('int/v1/fleet-ops/hubs/resources')
+ ->toContain('public/inspections/forms/{id}')
+ ->toContain('public/inspections/forms/{id}/submit')
+ ->toContain('public/inspections/forms/{id}/files');
+
+ // A link's PIN can be sent again from the console.
+ $sendPin = array_values(array_filter($recorder->routes, fn (array $route) => str_ends_with($route['uri'], 'inspection-forms/{id}/links/{linkId}/send-pin')));
+ expect($sendPin)->toHaveCount(1)
+ ->and($sendPin[0]['method'])->toBe('POST')
+ ->and($sendPin[0]['action'])->toBe('InspectionFormController@sendPin');
+
+ // Every inspection route answers in JSON, so a refused request is a 422
+ // rather than a redirect: the driver API, the vehicle history and the
+ // console's form and submission routes.
+ $json = Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class;
+ $inGroup = fn (array $route) => in_array($json, array_merge(...array_map(fn ($group) => (array) ($group['middleware'] ?? []), $route['groups'])), true)
+ || in_array($json, (array) ($route['middleware'] ?? []), true);
+ foreach (['InspectionController@findForm', 'InspectionController@submit', 'InspectionController@forVehicle'] as $action) {
+ $route = collect($recorder->routes)->first(fn (array $route) => $route['action'] === $action);
+ expect($route)->not->toBeNull()
+ ->and($inGroup($route))->toBeTrue();
+ }
+ $console = array_filter($recorder->routes, fn (array $route) => str_contains($route['uri'], 'inspection-forms/{id}/generate-link') || str_contains($route['uri'], 'inspection-submissions/{id}/submit'));
+ expect($console)->toHaveCount(2);
+ foreach ($console as $route) {
+ expect($inGroup($route))->toBeTrue();
+ }
+
+ // Uploads through a link have a tighter limit of their own, on top of the group's.
+ $upload = collect($recorder->routes)->firstWhere('uri', 'public/inspections/forms/{id}/files');
+ expect($upload['middleware'] ?? [])->toBe(['throttle:20,1,inspection-upload']);
});
test('fleetops route file wires route groups with expected middleware and namespaces', function () {
@@ -156,6 +239,14 @@ function fleetOpsRecordedRoutes(): FleetOpsRouteRecorder
'namespace' => 'Api\v1',
]);
+ // The public inspection routes answer in JSON whatever the client asks
+ // for, and are rate limited per address.
+ expect($recorder->groups)->toContainEqual([
+ 'prefix' => 'public',
+ 'namespace' => 'Public',
+ 'middleware' => [Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class, 'throttle:60,1,inspection-public'],
+ ]);
+
expect($recorder->groups)->toContainEqual([
'prefix' => 'int',
'namespace' => 'Internal',
diff --git a/server/tests/Unit/Http/Resources/ManifestResourceTest.php b/server/tests/Unit/Http/Resources/ManifestResourceTest.php
index 6878184c6..547d1bb2f 100644
--- a/server/tests/Unit/Http/Resources/ManifestResourceTest.php
+++ b/server/tests/Unit/Http/Resources/ManifestResourceTest.php
@@ -64,7 +64,7 @@ public function getVehicleNameAttribute(): ?string
}
test('manifest resource publishes what a driver needs to run a route', function () {
- $request = Request::create('/v1/manifests/manifest_public', 'GET');
+ $request = Request::create('/v1/manifests/manifest_public', 'GET');
FleetOpsSupportRequestState::$request = $request;
$manifest = new FleetOpsManifestFake();
@@ -94,7 +94,7 @@ public function getVehicleNameAttribute(): ?string
test('manifest resource omits stops when they were not loaded, so a list stays a list', function () {
// A driver's manifest list on a busy fleet must not drag every stop of
// every route along with it.
- $request = Request::create('/v1/drivers/driver_public/manifests', 'GET');
+ $request = Request::create('/v1/drivers/driver_public/manifests', 'GET');
FleetOpsSupportRequestState::$request = $request;
$manifest = new FleetOpsManifestFake();
@@ -106,7 +106,7 @@ public function getVehicleNameAttribute(): ?string
});
test('manifest stop resource carries the sequence a re-sequence rewrites', function () {
- $request = Request::create('/v1/manifest-stops/stop_public', 'GET');
+ $request = Request::create('/v1/manifest-stops/stop_public', 'GET');
FleetOpsSupportRequestState::$request = $request;
$stop = new ManifestStop();
diff --git a/translations/ar-ae.yml b/translations/ar-ae.yml
index d377b2870..066c9fd0f 100644
--- a/translations/ar-ae.yml
+++ b/translations/ar-ae.yml
@@ -2,6 +2,8 @@ menu:
fuel-providers: تكاملات الوقود
fuel-transactions: معاملات الوقود
trailers: المقطورات
+ inspection-forms: 'نماذج الفحص'
+ inspections: 'الفحوصات'
trailer:
navigation-description: إدارة الأصول المقطورة ووصلات القطر والمعدات والتتبع عن بُعد.
diff --git a/translations/bg-bg.yaml b/translations/bg-bg.yaml
index d211080a5..25913ea11 100644
--- a/translations/bg-bg.yaml
+++ b/translations/bg-bg.yaml
@@ -73,6 +73,8 @@ menu:
issues: Проблеми
maintenance: Поддръжка
work-orders: Работни поръчки
+ inspection-forms: 'Формуляри за инспекция'
+ inspections: 'Инспекции'
equipment: Оборудване
parts: Части
connectivity: Свързаност
diff --git a/translations/en-us.yaml b/translations/en-us.yaml
index ade8bc7e9..8babe2bc1 100644
--- a/translations/en-us.yaml
+++ b/translations/en-us.yaml
@@ -86,6 +86,8 @@ menu:
maintenance: Maintenance
schedules: Schedules
work-orders: Work Orders
+ inspection-forms: 'Inspection Forms'
+ inspections: 'Inspections'
equipment: Equipment
parts: Parts
maintenance-history: Maintenance History
@@ -2746,3 +2748,266 @@ orchestrator:
stop-type-dropoff: Dropoff
pod-required: POD
no-pod: No POD
+
+inspection:
+ form:
+ overview: Overview
+ details: Form Details
+ name: Name
+ name-placeholder: Daily vehicle inspection
+ type: Type
+ status: Status
+ description: Description
+ description-placeholder: What this inspection covers
+ builder: Form Builder
+ fields: Fields
+ published: Published
+ structure: Form Structure
+ loading-structure: Loading form structure...
+ no-structure: This form has no field groups yet.
+ settings: Settings
+ type-placeholder: Select the kind of inspection this form is
+ status-placeholder: Select a status
+ setting-create-issue: Open a defect record when an item fails
+ setting-create-issue-help: A failed inspection files an issue against the vehicle, so the failure has somewhere to live.
+ setting-create-work-order: Open a work order when an item fails
+ setting-create-work-order-help: The failed items become a work order checklist for the workshop. Needs workshops enabled.
+ setting-require-signature: Require the driver to sign
+ setting-require-signature-help: The driver app will not submit without a signature. Add a signature field to the form instead if you want it in a particular place.
+ legacy-checklist: Legacy Checklist
+ legacy-checklist-help: The first cut's checklist, kept read-only. It is migrated into a "Checklist" group of pass/fail fields.
+ create: Create inspection form
+ created: Inspection form created.
+ updated: Inspection form updated.
+ builder:
+ help: Group the things a driver checks, then add a field for each one.
+ loading: Loading form builder...
+ new-group: New field group
+ new-field: New field
+ edit-field: 'Edit field: {label}'
+ save-field: Save field
+ untitled-group: Untitled group
+ untitled-field: Untitled field
+ group-name: Group name
+ group-name-placeholder: Exterior
+ group-description: Group description
+ group-description-placeholder: What this group covers
+ grid-size: Columns
+ move-up: Move up
+ move-down: Move down
+ no-fields: No fields in this group yet.
+ empty-title: No field groups
+ empty-description: Add your first field group to start building this form.
+ delete: Delete
+ delete-group-title: Delete this field group?
+ delete-group-body: Deleting this group deletes every field inside it. This cannot be undone once the form is saved.
+ delete-field-title: Delete this field?
+ delete-field-body: Deleting this field also deletes every answer already filed against it. This cannot be undone once the form is saved.
+ field:
+ label: Field label
+ label-placeholder: Brake lights
+ name: Field name
+ name-help: The machine name a submission refers to this field by. It follows the label until you set it yourself.
+ name-placeholder: brake-lights
+ type: Field type
+ description: Field description
+ description-placeholder: What to check, and what a pass looks like
+ help-text: Field help text
+ help-text-placeholder: Shown beside the field while it is answered
+ required: Field is required
+ editable: Field is editable
+ options: Field options
+ no-options: No options yet.
+ option-placeholder: Add an option
+ add-option: Add
+ unit: Unit
+ unit-help: Shown beside the number, for example km or hours.
+ unit-placeholder: km
+ odometer-role: This is the odometer reading
+ odometer-role-help: The answer is copied to the inspection's own odometer column.
+ on-fail: On fail
+ on-fail-help: What a failed answer means, and what the inspector must supply before it is accepted.
+ default-severity: Default severity
+ require-photo-on-fail: Require a photo on fail
+ require-comment-on-fail: Require a comment on fail
+ unsafe-on-fail: Mark the vehicle unsafe on fail
+ unsafe-on-fail-help: A failed answer defaults to unsafe, which is what an out-of-service defect is.
+ instructions: Instructions
+ instructions-placeholder: How to check this, shown to the driver
+ column-span: Column span
+ field-type:
+ pass-fail: Pass / Fail
+ input: Text
+ textarea: Long text
+ number: Number
+ select: Select
+ radio-button: Radio buttons
+ boolean: Yes / No
+ date-picker: Date
+ date-time-input: Date and time
+ file-upload: Photo or file
+ signature: Signature
+ severity:
+ low: Low
+ medium: Medium
+ high: High
+ critical: Critical
+ answer:
+ pass: Pass
+ fail: Fail
+ not-applicable: N/A
+ unanswered: Unanswered
+ severity: Severity
+ unsafe: Unsafe to operate
+ comments: Comments
+ comments-placeholder: What is wrong, and what it needs
+ photos: Photos
+ add-photo: Add photo
+ upload-photo: Upload photo
+ no-photo: No photo attached.
+ upload-signature: Upload signature
+ no-signature: No signature captured.
+ select-placeholder: Choose an answer
+ note-placeholder: Add a note
+ text-placeholder: Type an answer
+ no-options: This field has no answers to choose from.
+ uploads-unavailable: Can be added from the console or the driver app.
+ comments-required: Say what is wrong, and what it needs
+ comment-required: Comment mandatory on fail
+ photo-required: Photo mandatory on fail
+ comment-and-photo-required: Comment + photo mandatory on fail
+ record:
+ overview: Overview
+ inspection: Inspection
+ details: Inspection Details
+ form: Form
+ select-form: Select inspection form
+ status: Status
+ result: Result
+ vehicle: Vehicle
+ select-vehicle: Select vehicle
+ driver: Driver
+ select-driver: Select driver
+ odometer: Odometer
+ odometer-placeholder: Current odometer
+ engine-hours: Engine Hours
+ engine-hours-placeholder: Current engine hours
+ answers: Answers
+ loading-form: Loading inspection form...
+ loading-answers: Loading answers...
+ form-has-no-fields: This form has no fields yet. Build it on the inspection form screen before filling it in.
+ summary: Summary
+ failed-of: '{failed} failed of {total} pass/fail checks'
+ metadata: Metadata
+ submitted: Submitted
+ submitted-by: Submitted By
+ via-link: Through a public link
+ via-link-pin: Through a public link, PIN verified
+ signed-as: 'Signed as "{name}"'
+ name-unverified: Name as typed, not a signed-in account
+ resolved: Resolved
+ item-results: Item Results
+ no-item-results: No item results recorded.
+ follow-up: Follow Up
+ linked-issue: Linked Issue
+ linked-work-order: Linked Work Order
+ photos: Photos
+ loading-photos: Loading photos...
+ audit: Audit
+ create: Create inspection
+ saved: Inspection saved.
+ updated: Inspection updated.
+ form-help: The published form this inspection is filled in from.
+ choose-a-form: Choose an inspection form to begin.
+ group-has-no-fields: This section has no checks.
+ outstanding: still to answer
+ section-outstanding: "{count, plural, one {# outstanding} other {# outstanding}}"
+ outstanding-field: "{label} is required before submitting"
+ review: "Review →"
+ jump-to: "Jump to →"
+ link:
+ public-links: Public Links
+ existing: Generated Links
+ modal-help: "Generate a single-use link for {form}. Whoever opens it enters its PIN, then fills in this published form without signing in."
+ loading: Loading links...
+ none: No links have been generated for this form yet.
+ load-failed: These links could not be loaded.
+ copy: Copy
+ copied: Inspection link copied.
+ revoke: Revoke
+ revoked: Inspection link revoked.
+ generated: Generated
+ generated-toast: Inspection link generated.
+ publish-first: Publish this inspection form before generating a public link.
+ expires: Expires
+ no-expiry: No expiry
+ used: Used
+ viewed: Last opened
+ never-opened: Never opened
+ unassigned: Anyone with the link and its PIN
+ url-not-kept: This link was generated before links were kept, so its URL cannot be shown again.
+ state-active: Active
+ state-expired: Expired
+ state-used: Used
+ state-revoked: Revoked
+ state-locked: Locked
+ expires-at: Expires at
+ expires-help: Links expire 72 hours after they are generated unless you choose a different time.
+ assign-to: Assign to
+ assign-to-help: Optional. Anyone in your organisation can complete an inspection; the submission is credited to them.
+ select-assignee: Select a user
+ pin: PIN
+ pin-delivery: Send the link and PIN
+ pin-delivery-none: Do not send it, I will share it myself
+ pin-delivery-email: By email
+ pin-delivery-sms: By text message
+ pin-delivery-help: "Sent to {name}, with the link to open and the PIN to enter."
+ pin-delivery-no-recipient: Assign the link to someone, or pick a driver, to send it to them. Otherwise share the link and PIN yourself; both are shown once it is generated.
+ generated-title: Link generated
+ generated-help: The link is copied to your clipboard. Whoever opens it needs the PIN too.
+ copied-pin: PIN copied.
+ email-pin: Email link
+ text-pin: Text link
+ pin-sent-email: "Link and PIN emailed to {to}."
+ pin-sent-sms: "Link and PIN texted to {to}."
+ pin-not-sent: "The link and PIN were not sent: {reason}"
+ pin-sent-by: "{via, select, email {Emailed} sms {Texted} other {Sent}}"
+ wrong-pins: "{count, plural, one {# wrong PIN} other {# wrong PINs}}"
+ no-pin: No PIN. This link was generated before links had PINs.
+ public:
+ submitted-title: Inspection submitted
+ submitted-body: Thank you. This inspection has been filed and the link is now closed.
+ unavailable-title: This inspection is not available
+ sign-off: Sign off
+ your-name: Your name
+ your-name-help: Recorded against this inspection as who filled it in.
+ your-name-placeholder: Full name
+ for: For
+ pin-title: Enter your PIN
+ pin-help: Enter the 6-digit PIN you were given for this inspection.
+ pin-label: PIN
+ pin-continue: Continue
+ pin-wrong: "That PIN is not right. {count, plural, one {# attempt} other {# attempts}} left before this link locks."
+ submit: Submit inspection
+ blocked-required: "{count, plural, one {# required answer is missing} other {# required answers are missing}}"
+ blocked-defects: "{count, plural, one {# failure still needs a comment or a photo} other {# failures still need a comment or a photo}}"
+ flyout:
+ title: "Defect: {label}"
+ done: Done
+ close: Close
+ defect:
+ edit: Edit defect
+ photos: "{count, plural, one {# photo} other {# photos}}"
+ comment: Comment
+ needs-comment: Needs a comment
+ needs-photo: Needs a photo
+ needs-both: Needs a comment and a photo
+ no-evidence: No comment or photo yet
+ tray:
+ title: Defects
+ review: Review
+select-option:
+ vehicle:
+ vin: VIN
+ serial_number: S/N
+ call_sign: Call sign
diff --git a/translations/es-pa.yaml b/translations/es-pa.yaml
index 67977f8f6..74f529137 100644
--- a/translations/es-pa.yaml
+++ b/translations/es-pa.yaml
@@ -70,6 +70,8 @@ menu:
issues: Asuntos
maintenance: Mantenimiento
work-orders: Órdenes de trabajo
+ inspection-forms: 'Formularios de inspección'
+ inspections: 'Inspecciones'
equipment: Equipo
parts: Partes
connectivity: Conectividad
diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml
index 7697ceda0..5da3f81d1 100644
--- a/translations/fr-fr.yaml
+++ b/translations/fr-fr.yaml
@@ -73,6 +73,8 @@ menu:
issues: Problèmes
maintenance: Maintenance
work-orders: Ordres de travail
+ inspection-forms: "Formulaires d'inspection"
+ inspections: "Inspections"
equipment: Équipement
parts: Pièces
connectivity: Connectivité
diff --git a/translations/mn-mn.yaml b/translations/mn-mn.yaml
index 461cd39d6..ecbb21d95 100644
--- a/translations/mn-mn.yaml
+++ b/translations/mn-mn.yaml
@@ -73,6 +73,8 @@ menu:
issues: Асуудлууд
maintenance: Үйлчилгээ
work-orders: Ажлын захиалгууд
+ inspection-forms: 'Үзлэгийн маягтууд'
+ inspections: 'Үзлэгүүд'
equipment: Тоног төхөөрөмж
parts: Сэлбэгүүд
connectivity: Холболт
diff --git a/translations/pt-br.yaml b/translations/pt-br.yaml
index a81180389..3e41da195 100644
--- a/translations/pt-br.yaml
+++ b/translations/pt-br.yaml
@@ -73,6 +73,8 @@ menu:
issues: Problemas
maintenance: Manutenção
work-orders: Ordens de Serviço
+ inspection-forms: 'Formulários de inspeção'
+ inspections: 'Inspeções'
equipment: Equipamentos
parts: Peças
connectivity: Conectividade
diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml
index 337f7c229..3243236e1 100644
--- a/translations/ru-ru.yaml
+++ b/translations/ru-ru.yaml
@@ -73,6 +73,8 @@ menu:
issues: Проблемы
maintenance: Обслуживание
work-orders: Рабочие заказы
+ inspection-forms: 'Формы осмотра'
+ inspections: 'Осмотры'
equipment: Оборудование
parts: Запчасти
connectivity: Подключение
diff --git a/translations/vi-vn.yaml b/translations/vi-vn.yaml
index 5d86b0b45..8a4820e33 100644
--- a/translations/vi-vn.yaml
+++ b/translations/vi-vn.yaml
@@ -2,6 +2,8 @@ menu:
fuel-providers: Tích hợp Nhiên liệu
fuel-transactions: Giao dịch nhiên liệu
trailers: Rơ-moóc
+ inspection-forms: 'Biểu mẫu kiểm tra'
+ inspections: 'Kiểm tra'
resource:
trailer: Rơ-moóc