feat(fp.job): post-shop transition helpers (Tasks 2+3)

_fp_check_advance_post_shop: in_progress + all steps terminal →
  awaiting_cert (cert required) or awaiting_ship. Auto-spawns cert
  + delivery and fires notifications. Idempotent. Does NOT raise —
  gate failures bubble up via fp.job.step.button_finish (Task 4).
_fp_check_advance_after_cert_issue: awaiting_cert → awaiting_ship
  when every required cert is state=issued.
_fp_check_regress_after_cert_void: awaiting_ship → awaiting_cert
  when a previously-issued cert is voided. Re-notifies QM.

hasattr guards on _fp_schedule_cert_activity + _fp_resolve_cert_activities
keep this safe during incremental rollout — those land in Task 20.

Test scaffolding added covering helper existence + idempotency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-25 09:21:22 -04:00
parent c2b693c97e
commit 5173554281
3 changed files with 203 additions and 0 deletions

View File

@@ -2144,6 +2144,108 @@ class FpJob(models.Model):
vals['coc_attachment_id'] = issued_cert.attachment_id.id
return vals
# ==================================================================
# Post-shop auto-advance helpers (spec 2026-05-25)
# ------------------------------------------------------------------
# When the last open recipe step finishes, the job auto-advances to
# awaiting_cert (if any cert is required) or awaiting_ship (if not).
# Cert issue auto-advances awaiting_cert → awaiting_ship. Cert void
# regresses awaiting_ship → awaiting_cert. All helpers are
# idempotent — safe to call from any hook.
# ==================================================================
def _fp_check_advance_post_shop(self):
"""Auto-advance in_progress jobs whose recipe steps are all
terminal. Called from fp.job.step.button_finish post-super().
Does NOT raise — gate failures (bake/qty/QC) are surfaced by
fp.job.step.button_finish BEFORE this is called (per spec D12).
At this point the step IS finished and the transition is safe.
Idempotent: re-running on a job already past in_progress is a
no-op.
"""
for job in self:
if job.state != 'in_progress':
continue
if not job.step_ids:
continue
if any(s.state not in ('done', 'skipped', 'cancelled')
for s in job.step_ids):
continue
required = job._resolve_required_cert_types() or set()
new_state = 'awaiting_cert' if required else 'awaiting_ship'
job.state = new_state
# Side effects that used to run in button_mark_done — still
# need to fire here so cert + delivery records exist.
if new_state == 'awaiting_cert':
job._fp_create_certificates()
job._fp_fire_notification('cert_awaiting_issuance')
# Forward reference — _fp_schedule_cert_activity is
# defined in Task 20. hasattr guard keeps this safe
# during incremental rollout.
if hasattr(job, '_fp_schedule_cert_activity'):
job._fp_schedule_cert_activity()
else:
job._fp_create_delivery()
job._fp_fire_notification('job_complete')
def _fp_check_advance_after_cert_issue(self):
"""Called from fp.certificate.action_issue. If every required
cert for this job is now `issued`, advance awaiting_cert →
awaiting_ship. Idempotent — safe to call repeatedly.
"""
for job in self:
if job.state != 'awaiting_cert':
continue
if 'fp.certificate' not in self.env:
continue
required = job._resolve_required_cert_types() or set()
if not required:
# Edge case: required set went empty after creation
# (e.g. partner flag toggled). Treat as "ready to ship".
job.state = 'awaiting_ship'
job._fp_create_delivery()
if hasattr(job, '_fp_resolve_cert_activities'):
job._fp_resolve_cert_activities()
continue
Cert = self.env['fp.certificate'].sudo()
outstanding = Cert.search_count([
('x_fc_job_id', '=', job.id),
('certificate_type', 'in', list(required)),
('state', '!=', 'issued'),
])
if outstanding == 0:
job.state = 'awaiting_ship'
job._fp_create_delivery()
if hasattr(job, '_fp_resolve_cert_activities'):
job._fp_resolve_cert_activities()
def _fp_check_regress_after_cert_void(self):
"""Called from fp.certificate.write when state=voided. If a
previously-issued cert is no longer issued, slide the job back
to awaiting_cert so it reappears in Final Inspection and the
QM is re-notified.
"""
for job in self:
if job.state != 'awaiting_ship':
continue
if 'fp.certificate' not in self.env:
continue
required = job._resolve_required_cert_types() or set()
if not required:
continue
Cert = self.env['fp.certificate'].sudo()
outstanding = Cert.search_count([
('x_fc_job_id', '=', job.id),
('certificate_type', 'in', list(required)),
('state', '!=', 'issued'),
])
if outstanding > 0:
job.state = 'awaiting_cert'
job._fp_fire_notification('cert_voided_re_notify')
if hasattr(job, '_fp_schedule_cert_activity'):
job._fp_schedule_cert_activity()
def _fp_create_certificates(self):
"""Auto-create one draft fp.certificate per type returned by
_resolve_required_cert_types. Idempotent per type — re-running