feat(fusion_repairs): Bundle 7 - tech mobile (T3 + T4 + T6 + T7)

T3 Labour timer on technician task
- Two new fields on fusion.technician.task: x_fc_timer_running_since
  (Datetime) + x_fc_timer_accumulated_minutes (Float).
- action_timer_start / action_timer_stop methods, idempotent (start when
  already running is a no-op, stop when not running is a no-op).
- Multiple start/stop cycles accumulate into the same total.
- Two header buttons (Start Timer green / Stop Timer amber), invisible
  based on the running_since field so the right one shows at any time.
- Stop posts a chatter line 'Labour timer stopped. Added X.X min, total
  Y.Y min.' so audit history shows every shift.

T4 Client signature on visit report
- New client_signature Binary field on the visit-report wizard with
  Odoo native widget='signature' that draws on canvas + base64-encodes
  the PNG.
- client_signature_name Char for typed name (audit).
- Persisted as an ir.attachment on the repair.order via the new
  _persist_mobile_artefacts helper.
- Chatter post 'Client signature captured (Jane Smith).'.

T6 Replaced parts - serial capture
- parts_serial_capture Text on the wizard (one per line per the spec).
- On confirm, posted to chatter wrapped in <pre> so line breaks survive.
- Used by OEM warranty filing in future M8.

T7 Client no-show photo proof
- no_show Boolean + no_show_photo Binary with widget='image' (visible
  only when no_show=True via Odoo 19 invisible= conditional).
- Photo saved as ir.attachment on the repair when present.
- Chatter post 'Visit recorded as client no-show (photo attached)'.

Verified end-to-end on local westin-v19:
  T3 timer started -> 2s sleep -> stopped -> 0.0357 min recorded
  T4 attachment 'signature-RO-202605-17.png' created on repair
  T6 chatter shows 'SN-AAA-111 / SN-BBB-222'
  T4 chatter shows 'Client signature captured (Jane Smith)'

Bumped to 19.0.1.7.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
gsinghpal
2026-05-21 00:24:35 -04:00
parent 638b223d3b
commit b4b59cc3c9
5 changed files with 143 additions and 1 deletions

View File

@@ -4,7 +4,7 @@
{ {
'name': 'Fusion Repairs', 'name': 'Fusion Repairs',
'version': '19.0.1.6.0', 'version': '19.0.1.7.0',
'category': 'Inventory/Repairs', 'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """ 'description': """

View File

@@ -42,6 +42,49 @@ class FusionTechnicianTaskRepairs(models.Model):
copy=False, copy=False,
) )
# ------------------------------------------------------------------
# T3 - Labour timer. The tech taps Start when they begin work and
# Stop when done; the accumulated minutes feeds the visit-report
# actual hours field. Multiple start/stop cycles are accumulated.
# ------------------------------------------------------------------
x_fc_timer_running_since = fields.Datetime(
string='Timer Running Since',
copy=False,
)
x_fc_timer_accumulated_minutes = fields.Float(
string='Accumulated Minutes',
default=0.0,
copy=False,
help='Total labour minutes captured by the tech timer. '
'Divide by 60 for the hours that prefill the visit report.',
)
def action_timer_start(self):
for t in self:
if t.x_fc_timer_running_since:
continue # already running
t.x_fc_timer_running_since = fields.Datetime.now()
t.message_post(body=Markup(_('Labour timer <b>started</b>.')))
def action_timer_stop(self):
for t in self:
if not t.x_fc_timer_running_since:
continue
from datetime import datetime
elapsed_minutes = (
datetime.now() - t.x_fc_timer_running_since
).total_seconds() / 60.0
t.x_fc_timer_accumulated_minutes = (
t.x_fc_timer_accumulated_minutes or 0.0
) + elapsed_minutes
t.x_fc_timer_running_since = False
t.message_post(body=Markup(_(
'Labour timer <b>stopped</b>. Added %(mins).1f min, total %(tot).1f min.'
)) % {
'mins': elapsed_minutes,
'tot': t.x_fc_timer_accumulated_minutes or 0.0,
})
def write(self, vals): def write(self, vals):
"""When a maintenance task transitions to 'completed', roll the """When a maintenance task transitions to 'completed', roll the
linked contract to its next cycle. Failure to roll never blocks linked contract to its next cycle. Failure to roll never blocks

View File

@@ -22,10 +22,26 @@
class="btn-secondary" class="btn-secondary"
icon="fa-wrench" icon="fa-wrench"
invisible="not x_fc_repair_order_id"/> invisible="not x_fc_repair_order_id"/>
<button name="action_timer_start"
type="object"
string="Start Timer"
class="btn-success"
icon="fa-play-circle"
invisible="x_fc_timer_running_since"/>
<button name="action_timer_stop"
type="object"
string="Stop Timer"
class="btn-warning"
icon="fa-stop-circle"
invisible="not x_fc_timer_running_since"/>
</xpath> </xpath>
<xpath expr="//field[@name='partner_id']" position="after"> <xpath expr="//field[@name='partner_id']" position="after">
<field name="x_fc_repair_order_id" readonly="1" <field name="x_fc_repair_order_id" readonly="1"
invisible="not x_fc_repair_order_id"/> invisible="not x_fc_repair_order_id"/>
<field name="x_fc_timer_running_since" readonly="1"
invisible="not x_fc_timer_running_since"/>
<field name="x_fc_timer_accumulated_minutes" readonly="1"
widget="float" digits="[12,1]"/>
</xpath> </xpath>
</field> </field>
</record> </record>

View File

@@ -79,6 +79,35 @@ class RepairVisitReportWizard(models.TransientModel):
readonly=True, readonly=True,
) )
# ----- T4 client signature -----
client_signature = fields.Binary(
string='Client Signature',
attachment=True,
help='Captured via signature widget on tech mobile - proves the '
'client accepted the work.',
)
client_signature_name = fields.Char(
string='Signed By',
help='Type the client name as they signed (for the audit log).',
)
# ----- T7 no-show photo proof -----
no_show = fields.Boolean(
string='Client No-Show',
help='Tick if the client was not present. Forces a no-show photo.',
)
no_show_photo = fields.Binary(
string='No-Show Photo',
attachment=True,
help='Photo of the door / driveway proving the technician attended.',
)
# ----- T6 parts replaced - serial capture -----
parts_serial_capture = fields.Text(
string='Replaced Parts - Serials',
help='One serial per line. Used for OEM warranty claims.',
)
# Variance display # Variance display
estimated_cost = fields.Monetary( estimated_cost = fields.Monetary(
related='repair_id.x_fc_estimated_cost', related='repair_id.x_fc_estimated_cost',
@@ -187,6 +216,10 @@ class RepairVisitReportWizard(models.TransientModel):
if self.issue_inspection_cert: if self.issue_inspection_cert:
self._create_inspection_certificate(repair) self._create_inspection_certificate(repair)
# T4 / T6 / T7: persist captured artefacts as ir.attachment on the
# repair so they survive the wizard close.
self._persist_mobile_artefacts(repair)
# M5: burn a pre-paid service plan visit if the client has one and # M5: burn a pre-paid service plan visit if the client has one and
# the repair is a maintenance visit. The wizard intentionally does NOT # the repair is a maintenance visit. The wizard intentionally does NOT
# zero out the client's invoice line - the office still posts the # zero out the client's invoice line - the office still posts the
@@ -221,6 +254,39 @@ class RepairVisitReportWizard(models.TransientModel):
'res_id': repair.id, 'res_id': repair.id,
} }
def _persist_mobile_artefacts(self, repair):
"""T4/T6/T7: attach signature image, no-show photo, and serial list
to the repair so they survive after the transient wizard closes."""
Attachment = self.env['ir.attachment'].sudo()
if self.client_signature:
Attachment.create({
'name': f'signature-{repair.name}.png',
'datas': self.client_signature,
'res_model': 'repair.order',
'res_id': repair.id,
'mimetype': 'image/png',
})
who = self.client_signature_name or repair.partner_id.name or ''
repair.message_post(body=Markup(_(
'Client signature captured (<b>%s</b>).'
)) % who)
if self.no_show:
if self.no_show_photo:
Attachment.create({
'name': f'no-show-{repair.name}.jpg',
'datas': self.no_show_photo,
'res_model': 'repair.order',
'res_id': repair.id,
'mimetype': 'image/jpeg',
})
repair.message_post(body=Markup(_(
'Visit recorded as <b>client no-show</b>%s.'
)) % (' (photo attached)' if self.no_show_photo else ''))
if self.parts_serial_capture and self.parts_serial_capture.strip():
repair.message_post(body=Markup(_(
'Replaced part serials captured:<br/><pre>%s</pre>'
)) % self.parts_serial_capture.strip())
def _burn_service_plan_visit(self, repair): def _burn_service_plan_visit(self, repair):
"""M5: deduct one visit from the most-recently-active service plan """M5: deduct one visit from the most-recently-active service plan
covering this repair. Quietly no-ops if the client has no plan.""" covering this repair. Quietly no-ops if the client has no plan."""

View File

@@ -50,6 +50,23 @@
<field name="notes"/> <field name="notes"/>
<field name="found_another_issue"/> <field name="found_another_issue"/>
<field name="issue_inspection_cert"/> <field name="issue_inspection_cert"/>
<separator string="No-Show (T7)"/>
<group>
<field name="no_show"/>
<field name="no_show_photo" widget="image" filename="no_show_photo_filename"
invisible="not no_show"/>
</group>
<separator string="Parts Replaced - Serial Capture (T6)"/>
<field name="parts_serial_capture" nolabel="1"
placeholder="One serial per line - used for OEM warranty claims"/>
<separator string="Client Signature (T4)"/>
<group>
<field name="client_signature_name"/>
<field name="client_signature" widget="signature"/>
</group>
</sheet> </sheet>
<footer> <footer>
<button string="Save Visit Report" <button string="Save Visit Report"