Compare commits
10 Commits
051094813e
...
5a039ae369
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a039ae369 | ||
|
|
aab6b9275b | ||
|
|
26a1086623 | ||
|
|
c00831a72a | ||
|
|
3a120dd400 | ||
|
|
4dc0a7cca5 | ||
|
|
4930a89970 | ||
|
|
72f0f182a6 | ||
|
|
5173554281 | ||
|
|
c2b693c97e |
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.7.9.3',
|
||||
'version': '19.0.8.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
|
||||
@@ -224,6 +224,13 @@ class FpCertificate(models.Model):
|
||||
[('draft', 'Draft'), ('issued', 'Issued'), ('voided', 'Voided')],
|
||||
string='Status', default='draft', tracking=True, required=True,
|
||||
)
|
||||
x_fc_age_hours = fields.Float(
|
||||
string='Age (hours)',
|
||||
compute='_compute_x_fc_age_hours',
|
||||
help='Hours since the cert was created. Drives the Quality '
|
||||
'Dashboard age chip and overdue filter. Non-stored — '
|
||||
'always fresh on read. Spec 2026-05-25.',
|
||||
)
|
||||
void_reason = fields.Text(string='Void Reason')
|
||||
notes = fields.Html(string='Notes')
|
||||
|
||||
@@ -285,6 +292,17 @@ class FpCertificate(models.Model):
|
||||
|
||||
@api.depends('thickness_reading_ids', 'thickness_reading_ids.nip_mils',
|
||||
'spec_min_mils', 'spec_max_mils')
|
||||
def _compute_x_fc_age_hours(self):
|
||||
"""Hours since cert creation. Non-stored, recomputed on read."""
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
for rec in self:
|
||||
if not rec.create_date:
|
||||
rec.x_fc_age_hours = 0.0
|
||||
continue
|
||||
delta = now - rec.create_date
|
||||
rec.x_fc_age_hours = delta.total_seconds() / 3600.0
|
||||
|
||||
def _compute_reading_stats(self):
|
||||
for rec in self:
|
||||
readings = rec.thickness_reading_ids
|
||||
@@ -420,6 +438,38 @@ class FpCertificate(models.Model):
|
||||
|
||||
# ----- State actions ----------------------------------------------------
|
||||
def action_issue(self):
|
||||
# ===== ACL guard (spec 2026-05-25 §ACL changes) ===============
|
||||
# Only QM / Manager / Owner can issue certificates. Two-layer
|
||||
# enforcement; view-level groups= on the button is the other
|
||||
# layer. Manager bypass via context for cron / scripted issuance.
|
||||
if not self.env.context.get('fp_skip_cert_authority_gate'):
|
||||
cert_authority_gids = []
|
||||
for xmlid in (
|
||||
'fusion_plating.group_fp_quality_manager',
|
||||
'fusion_plating.group_fp_manager',
|
||||
'fusion_plating.group_fp_owner',
|
||||
):
|
||||
grp = self.env.ref(xmlid, raise_if_not_found=False)
|
||||
if grp:
|
||||
cert_authority_gids.append(grp.id)
|
||||
if cert_authority_gids and not (
|
||||
set(self.env.user.all_group_ids.ids)
|
||||
& set(cert_authority_gids)
|
||||
):
|
||||
from odoo.exceptions import AccessError
|
||||
raise AccessError(_(
|
||||
'Only Quality Managers, Managers, and Owners can '
|
||||
'issue certificates. Ask your QM to review and '
|
||||
'issue this CoC.'
|
||||
))
|
||||
else:
|
||||
from markupsafe import Markup
|
||||
for rec in self:
|
||||
rec.message_post(body=Markup(_(
|
||||
'Cert authority gate <b>bypassed</b> by '
|
||||
'<b>%(u)s</b> (context flag '
|
||||
'fp_skip_cert_authority_gate).'
|
||||
)) % {'u': self.env.user.name})
|
||||
for rec in self:
|
||||
if rec.state != 'draft':
|
||||
raise UserError(_('Only draft certificates can be issued.'))
|
||||
@@ -607,6 +657,39 @@ class FpCertificate(models.Model):
|
||||
rec.name, e,
|
||||
)
|
||||
rec.message_post(body=_('Certificate issued.'))
|
||||
# Post-issue: ask the job to check whether ALL required
|
||||
# certs are now issued. If so, the helper auto-advances to
|
||||
# awaiting_ship and resolves any open Issue-CoC activity.
|
||||
# Spec 2026-05-25.
|
||||
if ('x_fc_job_id' in rec._fields and rec.x_fc_job_id
|
||||
and hasattr(rec.x_fc_job_id,
|
||||
'_fp_check_advance_after_cert_issue')):
|
||||
rec.x_fc_job_id._fp_check_advance_after_cert_issue()
|
||||
|
||||
def write(self, vals):
|
||||
"""Override to detect cert voiding and trigger the job state
|
||||
regress (awaiting_ship → awaiting_cert). Spec 2026-05-25.
|
||||
|
||||
Captures which certs were `issued` BEFORE the write, so we know
|
||||
post-write whether a void actually downgraded a previously-
|
||||
issued cert. Calls the inverse-direction helper on the job.
|
||||
"""
|
||||
was_issued = {}
|
||||
if 'state' in vals and vals['state'] == 'voided':
|
||||
was_issued = {
|
||||
rec.id: (rec.state == 'issued')
|
||||
for rec in self
|
||||
}
|
||||
result = super().write(vals)
|
||||
if was_issued:
|
||||
for rec in self:
|
||||
if not was_issued.get(rec.id):
|
||||
continue # wasn't issued — no regress
|
||||
if ('x_fc_job_id' in rec._fields and rec.x_fc_job_id
|
||||
and hasattr(rec.x_fc_job_id,
|
||||
'_fp_check_regress_after_cert_void')):
|
||||
rec.x_fc_job_id._fp_check_regress_after_cert_void()
|
||||
return result
|
||||
|
||||
def _fp_sync_coc_to_delivery(self):
|
||||
"""Push this CoC's attachment onto its job's delivery so the
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
rule 13f.) -->
|
||||
<button name="action_issue" string="Issue"
|
||||
type="object" class="btn-primary"
|
||||
invisible="state != 'draft'"/>
|
||||
invisible="state != 'draft'"
|
||||
groups="fusion_plating.group_fp_quality_manager,fusion_plating.group_fp_manager,fusion_plating.group_fp_owner"/>
|
||||
<!-- Print = the same EN report action the gear-menu
|
||||
Print > Certificate of Conformance (English)
|
||||
calls. Routes through fusion_pdf_preview's
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.10.31.0',
|
||||
'version': '19.0.11.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -53,6 +53,8 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
'security/legacy_groups.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/fp_cron_data.xml',
|
||||
# Spec 2026-05-25 — mail.activity.type for QM Issue-CoC nudge.
|
||||
'data/fp_activity_types_data.xml',
|
||||
# Sub 14 — workflow state catalog (must load before fp_job_form_inherit
|
||||
# so the statusbar's m2o has its targets available at view-render time).
|
||||
'data/fp_workflow_state_data.xml',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
|
||||
|
||||
mail.activity.type for the belt-and-suspenders in-app activity
|
||||
assigned to a QM when a job transitions to awaiting_cert. Auto-
|
||||
resolves when the cert is issued and the job advances to
|
||||
awaiting_ship.
|
||||
|
||||
noupdate="1" so admin edits in the UI survive -u.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="activity_type_issue_coc" model="mail.activity.type">
|
||||
<field name="name">Issue CoC</field>
|
||||
<field name="summary">Issue Certificate of Conformance</field>
|
||||
<field name="icon">fa-certificate</field>
|
||||
<field name="delay_count">1</field>
|
||||
<field name="delay_unit">days</field>
|
||||
<field name="delay_from">current_date</field>
|
||||
<field name="res_model">fp.job</field>
|
||||
<field name="default_note">Job has finished the shop floor. Review the inspection prompts captured on the final step, then issue the CoC from the Quality Dashboard.</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,91 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Backfill new awaiting_cert / awaiting_ship states for mid-flight jobs.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
|
||||
|
||||
Rules:
|
||||
- in_progress + all steps terminal + draft cert exists → awaiting_cert
|
||||
- in_progress + all steps terminal + no cert required → awaiting_ship
|
||||
- done jobs LEFT ALONE — historically completed (already shipped)
|
||||
|
||||
Idempotent: re-running on a fresh upgrade is a no-op because no
|
||||
in_progress job will match the all-terminal predicate after the first
|
||||
run. Pass 1 and Pass 2 are mutually exclusive (the cert-existence
|
||||
sub-queries are inverses).
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""Post-migrate entrypoint — called by Odoo after the module's
|
||||
XML/Python loads on -u of fusion_plating_jobs."""
|
||||
|
||||
# ---- Pass 1: in_progress + all-terminal + draft cert → awaiting_cert
|
||||
cr.execute("""
|
||||
UPDATE fp_job
|
||||
SET state = 'awaiting_cert'
|
||||
WHERE id IN (
|
||||
SELECT j.id
|
||||
FROM fp_job j
|
||||
JOIN fp_job_step s ON s.job_id = j.id
|
||||
WHERE j.state = 'in_progress'
|
||||
GROUP BY j.id
|
||||
HAVING count(*) FILTER (
|
||||
WHERE s.state NOT IN ('done','skipped','cancelled')
|
||||
) = 0
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM fp_certificate c
|
||||
WHERE c.x_fc_job_id = fp_job.id AND c.state = 'draft'
|
||||
);
|
||||
""")
|
||||
n_cert = cr.rowcount
|
||||
_logger.info(
|
||||
"post-migrate 19.0.11.0.0: %d jobs migrated to awaiting_cert", n_cert,
|
||||
)
|
||||
|
||||
# ---- Pass 2: in_progress + all-terminal + no cert → awaiting_ship
|
||||
cr.execute("""
|
||||
UPDATE fp_job
|
||||
SET state = 'awaiting_ship'
|
||||
WHERE id IN (
|
||||
SELECT j.id
|
||||
FROM fp_job j
|
||||
JOIN fp_job_step s ON s.job_id = j.id
|
||||
WHERE j.state = 'in_progress'
|
||||
GROUP BY j.id
|
||||
HAVING count(*) FILTER (
|
||||
WHERE s.state NOT IN ('done','skipped','cancelled')
|
||||
) = 0
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fp_certificate c
|
||||
WHERE c.x_fc_job_id = fp_job.id
|
||||
AND c.state IN ('draft', 'issued')
|
||||
);
|
||||
""")
|
||||
n_ship = cr.rowcount
|
||||
_logger.info(
|
||||
"post-migrate 19.0.11.0.0: %d jobs migrated to awaiting_ship", n_ship,
|
||||
)
|
||||
|
||||
# ---- Card_state recompute for affected rows (stored compute) ----
|
||||
if n_cert or n_ship:
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
affected = env['fp.job'].search([
|
||||
('state', 'in', ('awaiting_cert', 'awaiting_ship')),
|
||||
])
|
||||
# Bust cache then read-to-recompute via @api.depends.
|
||||
affected.invalidate_recordset(['card_state', 'mini_timeline_json'])
|
||||
affected.mapped('card_state')
|
||||
affected.mapped('mini_timeline_json')
|
||||
_logger.info(
|
||||
"post-migrate 19.0.11.0.0: card_state recomputed on %d jobs",
|
||||
len(affected),
|
||||
)
|
||||
@@ -34,6 +34,21 @@ _COLUMN_SEQUENCE = [
|
||||
class FpJob(models.Model):
|
||||
_inherit = 'fp.job'
|
||||
|
||||
# ===== Post-shop state extension (spec 2026-05-25) =================
|
||||
# Two intermediate states between in_progress and done so completed
|
||||
# jobs awaiting cert + shipping stay visible on the Shop Floor board.
|
||||
# See docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
|
||||
state = fields.Selection(
|
||||
selection_add=[
|
||||
('awaiting_cert', 'Awaiting Cert'),
|
||||
('awaiting_ship', 'Awaiting Ship'),
|
||||
],
|
||||
ondelete={
|
||||
'awaiting_cert': 'set default',
|
||||
'awaiting_ship': 'set default',
|
||||
},
|
||||
)
|
||||
|
||||
# ---- Tier 3 mirrors from sale.order -----------------------------
|
||||
# Related (not stored) — pure display mirrors. Values may change on
|
||||
# the SO after job confirm (e.g. customer changes carrier preference)
|
||||
@@ -272,6 +287,13 @@ class FpJob(models.Model):
|
||||
if not job.active_step_id:
|
||||
if job.state == 'done':
|
||||
job.card_state = 'done'
|
||||
elif job.state == 'awaiting_cert':
|
||||
# Spec 2026-05-25 — state drives card_state for
|
||||
# post-shop jobs (active_step_id is False because
|
||||
# every step is terminal).
|
||||
job.card_state = 'awaiting_cert'
|
||||
elif job.state == 'awaiting_ship':
|
||||
job.card_state = 'awaiting_ship'
|
||||
elif (job.state == 'confirmed'
|
||||
and job._fp_inbound_not_received()):
|
||||
job.card_state = 'no_parts'
|
||||
@@ -312,6 +334,17 @@ class FpJob(models.Model):
|
||||
and step._fp_is_idle(threshold_hours=8)):
|
||||
job.card_state = 'idle_warning'
|
||||
continue
|
||||
# Rule 7.5 — awaiting_cert + awaiting_ship (spec 2026-05-25)
|
||||
# State drives card_state regardless of step state. Inserted
|
||||
# BEFORE the done rule because state='done' jobs are filtered
|
||||
# off the board upstream so the done rule is unreachable
|
||||
# from cards we'd actually render.
|
||||
if job.state == 'awaiting_cert':
|
||||
job.card_state = 'awaiting_cert'
|
||||
continue
|
||||
if job.state == 'awaiting_ship':
|
||||
job.card_state = 'awaiting_ship'
|
||||
continue
|
||||
# Rule 8 — done
|
||||
if step.area_kind == 'shipping' and job.state == 'done':
|
||||
job.card_state = 'done'
|
||||
@@ -341,10 +374,50 @@ class FpJob(models.Model):
|
||||
'step_ids.area_kind',
|
||||
'active_step_id',
|
||||
'card_state',
|
||||
'state',
|
||||
)
|
||||
def _compute_mini_timeline_json(self):
|
||||
"""9-element JSON array, one per Shop Floor column."""
|
||||
"""9-element JSON array, one per Shop Floor column.
|
||||
|
||||
For awaiting_cert / awaiting_ship (spec 2026-05-25): the
|
||||
Final-inspection or Shipping dot renders as 'current' with the
|
||||
state-named variant; all earlier dots render 'done'. Lets the
|
||||
QM see at a glance "this card has cleared the line, just
|
||||
waiting on paperwork/shipping".
|
||||
"""
|
||||
for job in self:
|
||||
# Post-shop state override (spec 2026-05-25): visually walk
|
||||
# the card across the two right-most columns even though
|
||||
# the recipe may not have steps with those area_kinds.
|
||||
if job.state == 'awaiting_cert':
|
||||
timeline = []
|
||||
for area in _COLUMN_SEQUENCE:
|
||||
if area == 'inspection':
|
||||
timeline.append({
|
||||
'area': area,
|
||||
'state': 'current',
|
||||
'variant': 'awaiting_cert',
|
||||
})
|
||||
elif area == 'shipping':
|
||||
timeline.append({'area': area, 'state': 'upcoming'})
|
||||
else:
|
||||
timeline.append({'area': area, 'state': 'done'})
|
||||
job.mini_timeline_json = json.dumps(timeline)
|
||||
continue
|
||||
if job.state == 'awaiting_ship':
|
||||
timeline = []
|
||||
for area in _COLUMN_SEQUENCE:
|
||||
if area == 'shipping':
|
||||
timeline.append({
|
||||
'area': area,
|
||||
'state': 'current',
|
||||
'variant': 'awaiting_ship',
|
||||
})
|
||||
else:
|
||||
timeline.append({'area': area, 'state': 'done'})
|
||||
job.mini_timeline_json = json.dumps(timeline)
|
||||
continue
|
||||
# Standard path — pre-existing logic.
|
||||
active_area = (job.active_step_id.area_kind
|
||||
if job.active_step_id else None)
|
||||
timeline = []
|
||||
@@ -584,17 +657,33 @@ class FpJob(models.Model):
|
||||
job.next_milestone_action = False
|
||||
job.next_milestone_label = ''
|
||||
continue
|
||||
if job.state != 'done':
|
||||
# New state machine (spec 2026-05-25). The auto-advance
|
||||
# helper normally fires button_finish post-super, so we
|
||||
# rarely see state='in_progress' here. When we do (e.g.
|
||||
# historical jobs caught mid-migration, or jobs whose
|
||||
# cert/delivery infra failed mid-transition), surface
|
||||
# mark_done as a manual fallback.
|
||||
if job.state == 'in_progress':
|
||||
job.next_milestone_action = 'mark_done'
|
||||
elif job._fp_has_draft_required_certs():
|
||||
elif job.state == 'awaiting_cert':
|
||||
job.next_milestone_action = 'issue_certs'
|
||||
elif (not job.delivery_id
|
||||
or job.delivery_id.state == 'draft'):
|
||||
job.next_milestone_action = 'schedule_delivery'
|
||||
elif job.delivery_id.state in ('scheduled', 'in_transit'):
|
||||
elif job.state == 'awaiting_ship':
|
||||
job.next_milestone_action = 'mark_shipped'
|
||||
elif job.state == 'done':
|
||||
# Legacy path — historical jobs that closed before the
|
||||
# new state machine landed. Preserve the old cascade
|
||||
# so their milestone buttons keep working.
|
||||
if job._fp_has_draft_required_certs():
|
||||
job.next_milestone_action = 'issue_certs'
|
||||
elif (not job.delivery_id
|
||||
or job.delivery_id.state == 'draft'):
|
||||
job.next_milestone_action = 'schedule_delivery'
|
||||
elif job.delivery_id.state in ('scheduled', 'in_transit'):
|
||||
job.next_milestone_action = 'mark_shipped'
|
||||
else:
|
||||
job.next_milestone_action = 'closed'
|
||||
else:
|
||||
job.next_milestone_action = 'closed'
|
||||
job.next_milestone_action = False
|
||||
job.next_milestone_label = labels.get(
|
||||
job.next_milestone_action, ''
|
||||
)
|
||||
@@ -631,7 +720,10 @@ class FpJob(models.Model):
|
||||
'mark_done': self.button_mark_done,
|
||||
'issue_certs': self._action_open_draft_certs,
|
||||
'schedule_delivery': self._action_open_draft_delivery,
|
||||
'mark_shipped': self._action_mark_active_delivery_delivered,
|
||||
# Spec 2026-05-25: dispatch between the new state-machine
|
||||
# path (state=awaiting_ship → button_mark_shipped) and the
|
||||
# legacy delivery path (state=done + scheduled delivery).
|
||||
'mark_shipped': self._action_mark_shipped_dispatch,
|
||||
}
|
||||
fn = action_map.get(self.next_milestone_action)
|
||||
if not fn:
|
||||
@@ -719,6 +811,18 @@ class FpJob(models.Model):
|
||||
) % self.delivery_id.name)
|
||||
return True
|
||||
|
||||
def _action_mark_shipped_dispatch(self):
|
||||
"""Dispatch the milestone-cascade 'Mark Shipped' button to the
|
||||
right handler based on job state. Spec 2026-05-25:
|
||||
- awaiting_ship → button_mark_shipped (new state machine)
|
||||
- done + active delivery → _action_mark_active_delivery_delivered
|
||||
(legacy historical path)
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state == 'awaiting_ship':
|
||||
return self.button_mark_shipped()
|
||||
return self._action_mark_active_delivery_delivered()
|
||||
|
||||
@api.depends(
|
||||
'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids',
|
||||
'step_ids.time_log_ids', 'origin', 'partner_id',
|
||||
@@ -1969,6 +2073,11 @@ class FpJob(models.Model):
|
||||
"Job %s requires QC. A new check has been created — "
|
||||
"complete it before marking the job Done."
|
||||
) % job.name)
|
||||
# When called as a gate-check from fp.job.step.button_finish
|
||||
# (per spec 2026-05-25 D12), exit BEFORE flipping state —
|
||||
# the post-shop advance helper handles the actual transition.
|
||||
if self.env.context.get('fp_check_gates_only'):
|
||||
continue
|
||||
job.state = 'done'
|
||||
job.date_finished = fields.Datetime.now()
|
||||
if not skip_side_effects:
|
||||
@@ -1977,6 +2086,32 @@ class FpJob(models.Model):
|
||||
job._fp_fire_notification('job_complete')
|
||||
return True
|
||||
|
||||
def button_mark_shipped(self):
|
||||
"""Manual transition awaiting_ship → done. Operator-facing
|
||||
button on the job form; restricted to Manager / Owner via
|
||||
groups= on the view button.
|
||||
|
||||
Does NOT re-run the bake/qty/QC gates — those passed when the
|
||||
job first transitioned out of in_progress. This is just the
|
||||
"yes, shipped" stamp.
|
||||
|
||||
Future hook: delivery.action_mark_delivered will call this
|
||||
automatically — out of scope for this iteration (spec 2026-05-25).
|
||||
"""
|
||||
for job in self:
|
||||
if job.state != 'awaiting_ship':
|
||||
raise UserError(_(
|
||||
'Job %s cannot be marked Shipped — state is "%s" '
|
||||
'(expected "awaiting_ship").'
|
||||
) % (job.name, job.state))
|
||||
job.state = 'done'
|
||||
job.date_finished = fields.Datetime.now()
|
||||
job._fp_fire_notification('job_shipped')
|
||||
job.message_post(body=_(
|
||||
'Marked shipped by %s.'
|
||||
) % self.env.user.name)
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Notifications dispatch (Phase 4)
|
||||
#
|
||||
@@ -2129,6 +2264,204 @@ 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_finish_gates(self):
|
||||
"""Run the bake-window / qty-reconciliation / QC gates that used
|
||||
to live in button_mark_done. Called from
|
||||
fp.job.step.button_finish when the operator is finishing the
|
||||
LAST open step on the job (spec D12).
|
||||
|
||||
Raises UserError on failure — operator stays on the step, fixes
|
||||
the issue, retries the click. Manager bypass via the same
|
||||
context flags as button_mark_done (fp_skip_bake_gate,
|
||||
fp_skip_qty_reconcile, fp_skip_qc_gate).
|
||||
|
||||
The trick: re-uses button_mark_done's gate logic but short-
|
||||
circuits BEFORE the state flip via the fp_check_gates_only
|
||||
context flag (honoured in button_mark_done below).
|
||||
"""
|
||||
self.ensure_one()
|
||||
# Pass through with context flag; button_mark_done will run all
|
||||
# its gates and then exit before flipping state. The actual
|
||||
# state transition (in_progress → awaiting_cert/ship) is owned
|
||||
# by _fp_check_advance_post_shop running AFTER super().button_finish.
|
||||
self.with_context(
|
||||
fp_check_gates_only=True,
|
||||
).button_mark_done()
|
||||
|
||||
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_schedule_cert_activity(self):
|
||||
"""Schedule an Issue-CoC mail.activity for one QM. Round-robin
|
||||
by oldest login_date (least recently active QM, likely least
|
||||
busy). Idempotent — re-firing while an open activity already
|
||||
exists is a no-op.
|
||||
|
||||
Spec 2026-05-25 §mail.activity belt + suspenders.
|
||||
"""
|
||||
self.ensure_one()
|
||||
activity_type = self.env.ref(
|
||||
'fusion_plating_jobs.activity_type_issue_coc',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not activity_type:
|
||||
return
|
||||
# Idempotency: skip if an open activity of this type exists.
|
||||
existing = self.activity_ids.filtered(
|
||||
lambda a: a.activity_type_id == activity_type
|
||||
)
|
||||
if existing:
|
||||
return
|
||||
Template = self.env.get('fp.notification.template')
|
||||
if not Template or not hasattr(
|
||||
Template, '_fp_resolve_cert_authority_users'):
|
||||
return
|
||||
qms = Template.sudo()._fp_resolve_cert_authority_users(self)
|
||||
if not qms:
|
||||
return
|
||||
# Round-robin: pick the QM who logged in least recently (likely
|
||||
# least busy). NULL login_date sorts first.
|
||||
qm = qms.sorted(
|
||||
lambda u: u.login_date or fields.Datetime.from_string(
|
||||
'1970-01-01 00:00:00'
|
||||
)
|
||||
)[:1]
|
||||
try:
|
||||
self.activity_schedule(
|
||||
activity_type_id=activity_type.id,
|
||||
user_id=qm.id,
|
||||
summary=_('Issue CoC for %s') % (
|
||||
self.display_wo_name or self.name or 'job'
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Job %s: schedule cert activity failed: %s", self.name, e,
|
||||
)
|
||||
|
||||
def _fp_resolve_cert_activities(self):
|
||||
"""Auto-resolve all open Issue-CoC activities on this job.
|
||||
Called from _fp_check_advance_after_cert_issue when the job
|
||||
transitions awaiting_cert → awaiting_ship. Spec 2026-05-25.
|
||||
"""
|
||||
self.ensure_one()
|
||||
activity_type = self.env.ref(
|
||||
'fusion_plating_jobs.activity_type_issue_coc',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not activity_type:
|
||||
return
|
||||
open_activities = self.activity_ids.filtered(
|
||||
lambda a: a.activity_type_id == activity_type
|
||||
)
|
||||
for act in open_activities:
|
||||
try:
|
||||
act.action_feedback(feedback=_('Cert issued — auto-resolved.'))
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Job %s: auto-resolve cert activity failed: %s",
|
||||
self.name, e,
|
||||
)
|
||||
|
||||
def _fp_create_certificates(self):
|
||||
"""Auto-create one draft fp.certificate per type returned by
|
||||
_resolve_required_cert_types. Idempotent per type — re-running
|
||||
|
||||
@@ -1311,8 +1311,36 @@ class FpJobStep(models.Model):
|
||||
self._fp_check_contract_review_complete()
|
||||
self._fp_check_receiving_gate()
|
||||
|
||||
# ----- Post-shop gate (spec 2026-05-25 D12) ---------------------
|
||||
# When finishing the LAST open step on an in_progress job, run
|
||||
# the bake/qty/QC gates that used to live in button_mark_done.
|
||||
# Failure raises UserError on THIS click — operator fixes
|
||||
# (qty, bake, QC) and retries the finish. Without this the
|
||||
# auto-advance helper would silently fail with no error path.
|
||||
for step in self:
|
||||
if step.state not in ('in_progress', 'paused', 'ready'):
|
||||
continue
|
||||
job = step.job_id
|
||||
if not job or job.state != 'in_progress':
|
||||
continue
|
||||
# Would this finish leave every step terminal?
|
||||
siblings_open = job.step_ids.filtered(
|
||||
lambda s: s.id != step.id
|
||||
and s.state not in ('done', 'skipped', 'cancelled')
|
||||
)
|
||||
if siblings_open:
|
||||
continue # not the last open step — skip the gates
|
||||
job._fp_check_finish_gates()
|
||||
|
||||
result = super().button_finish()
|
||||
|
||||
# ----- Post-shop auto-advance (spec 2026-05-25) -----------------
|
||||
# After super().button_finish flips step state to done, ask the
|
||||
# job to check whether ALL steps are now terminal. If so the
|
||||
# helper auto-advances state to awaiting_cert / awaiting_ship.
|
||||
for job in self.mapped('job_id'):
|
||||
job._fp_check_advance_post_shop()
|
||||
|
||||
# ----- Post-finish side effects --------------------------------
|
||||
BW = self.env['fusion.plating.bake.window']
|
||||
Bath = self.env['fusion.plating.bath']
|
||||
|
||||
@@ -7,3 +7,4 @@ from . import test_blocker_compute
|
||||
from . import test_late_risk_ratio
|
||||
from . import test_active_step_id
|
||||
from . import test_autopause_cron
|
||||
from . import test_post_shop_states
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Post-shop state transitions (awaiting_cert + awaiting_ship).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-25-post-shop-cert-shipping-job-states-plan.md
|
||||
"""
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestPostShopAdvance(TransactionCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.partner = self.env['res.partner'].create({'name': 'Cust'})
|
||||
self.product = self.env['product.product'].create({'name': 'Widget'})
|
||||
|
||||
def _make_job(self, state='in_progress', **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'state': state,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.job'].create(vals)
|
||||
|
||||
# ===== Task 2 — _fp_check_advance_post_shop helper ==================
|
||||
|
||||
def test_advance_helper_exists(self):
|
||||
job = self._make_job()
|
||||
self.assertTrue(hasattr(job, '_fp_check_advance_post_shop'))
|
||||
|
||||
def test_advance_noop_when_state_not_in_progress(self):
|
||||
# confirmed jobs should not be auto-advanced
|
||||
job = self._make_job(state='confirmed')
|
||||
job._fp_check_advance_post_shop()
|
||||
self.assertEqual(job.state, 'confirmed')
|
||||
|
||||
def test_advance_noop_when_no_steps(self):
|
||||
# job with zero steps stays put — nothing to evaluate
|
||||
job = self._make_job(state='in_progress')
|
||||
self.assertFalse(job.step_ids)
|
||||
job._fp_check_advance_post_shop()
|
||||
self.assertEqual(job.state, 'in_progress')
|
||||
|
||||
# ===== Task 3 — cert-issue + cert-void helpers =====================
|
||||
|
||||
def test_advance_after_cert_issue_helper_exists(self):
|
||||
job = self._make_job()
|
||||
self.assertTrue(hasattr(job, '_fp_check_advance_after_cert_issue'))
|
||||
|
||||
def test_regress_after_cert_void_helper_exists(self):
|
||||
job = self._make_job()
|
||||
self.assertTrue(hasattr(job, '_fp_check_regress_after_cert_void'))
|
||||
|
||||
def test_advance_after_cert_issue_idempotent_when_state_wrong(self):
|
||||
# Calling on a draft job is a no-op.
|
||||
job = self._make_job(state='draft')
|
||||
job._fp_check_advance_after_cert_issue()
|
||||
self.assertEqual(job.state, 'draft')
|
||||
|
||||
# ===== Task 4 — button_finish gates + auto-advance =================
|
||||
|
||||
def test_button_finish_on_last_step_triggers_advance(self):
|
||||
"""Finishing the only step of an in_progress job flips state
|
||||
to awaiting_ship (no cert required for this partner)."""
|
||||
if 'fp.job.step' not in self.env:
|
||||
self.skipTest('fp.job.step not available')
|
||||
job = self._make_job(state='in_progress')
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': job.id,
|
||||
'name': 'Final Inspection',
|
||||
'state': 'in_progress',
|
||||
'sequence': 10,
|
||||
})
|
||||
step.button_finish()
|
||||
self.assertEqual(job.state, 'awaiting_ship')
|
||||
|
||||
# ===== Task 5 — button_mark_shipped ================================
|
||||
|
||||
def test_button_mark_shipped_requires_awaiting_ship(self):
|
||||
from odoo.exceptions import UserError
|
||||
job = self._make_job(state='in_progress')
|
||||
with self.assertRaises(UserError):
|
||||
job.button_mark_shipped()
|
||||
|
||||
def test_button_mark_shipped_from_awaiting_ship_lands_done(self):
|
||||
job = self._make_job(state='awaiting_ship')
|
||||
job.button_mark_shipped()
|
||||
self.assertEqual(job.state, 'done')
|
||||
self.assertTrue(job.date_finished)
|
||||
|
||||
# ===== Task 20 — activity helpers ==================================
|
||||
|
||||
def test_schedule_cert_activity_helper_exists(self):
|
||||
job = self._make_job()
|
||||
self.assertTrue(hasattr(job, '_fp_schedule_cert_activity'))
|
||||
|
||||
def test_resolve_cert_activities_helper_exists(self):
|
||||
job = self._make_job()
|
||||
self.assertTrue(hasattr(job, '_fp_resolve_cert_activities'))
|
||||
@@ -68,7 +68,8 @@
|
||||
string="Mark Shipped"
|
||||
class="btn-success"
|
||||
icon="fa-paper-plane"
|
||||
invisible="next_milestone_action != 'mark_shipped'"/>
|
||||
invisible="next_milestone_action != 'mark_shipped'"
|
||||
groups="fusion_plating.group_fp_manager,fusion_plating.group_fp_owner"/>
|
||||
<field name="all_steps_terminal" invisible="1"/>
|
||||
<field name="next_milestone_action" invisible="1"/>
|
||||
<button name="action_print_sticker" type="object"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Notifications',
|
||||
'version': '19.0.6.6.1',
|
||||
'version': '19.0.7.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -31,6 +31,7 @@
|
||||
'security/ir.model.access.csv',
|
||||
'data/mail_template_data.xml',
|
||||
'data/fp_notification_template_data.xml',
|
||||
'data/fp_cert_authority_templates.xml',
|
||||
'views/fp_notification_template_views.xml',
|
||||
'views/fp_notification_log_views.xml',
|
||||
'views/fp_notifications_menu.xml',
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
|
||||
|
||||
Templates for the two new internal-recipient events that fire on
|
||||
fp.job state transitions through awaiting_cert. Recipients are
|
||||
resolved by _fp_resolve_cert_authority_users (QM / Manager / Owner
|
||||
via all_group_ids, transitive). The mail templates are bound to
|
||||
fp.job — the source record passed to _dispatch.
|
||||
|
||||
noupdate="1" so admin edits in the UI survive -u (per CLAUDE.md
|
||||
Rule 22 / mail-template gotcha).
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 1. Cert Awaiting Issuance (Warn, #F59E0B) -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="fp_mail_template_cert_awaiting_issuance" model="mail.template">
|
||||
<field name="name">FP: Cert Awaiting Issuance</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="subject">🏷️ Job {{ object.display_wo_name or object.name }} ready for CoC issuance</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #F59E0B; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #F59E0B; font-weight: 600; margin-bottom: 8px;">
|
||||
Quality Action Required
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">CoC Awaiting Issuance</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.75;">
|
||||
Job <strong t-out="object.display_wo_name or object.name"/>
|
||||
(<t t-out="object.partner_id.name"/>) has finished the shop floor
|
||||
and is awaiting CoC issuance.
|
||||
</p>
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
|
||||
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
|
||||
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Value</th>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Customer</td>
|
||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.partner_id.name or ''"/></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Part</td>
|
||||
<td style="padding: 8px 4px; text-align: right; font-family: monospace;">
|
||||
<t t-out="(object.part_catalog_id.part_number if object.part_catalog_id else '') or '—'"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Quantity</td>
|
||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.qty_done or 0"/></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Recipe</td>
|
||||
<td style="padding: 8px 4px; text-align: right;">
|
||||
<t t-out="(object.recipe_id.name if object.recipe_id else '') or '—'"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 16px 0; font-size: 14px; opacity: 0.75;">
|
||||
Review the inspection prompts captured by the operator on the
|
||||
Final Inspection step, then issue the CoC from the Quality
|
||||
Dashboard.
|
||||
</p>
|
||||
<p style="margin: 20px 0;">
|
||||
<a t-attf-href="/odoo/action-fusion_plating_quality.action_fp_quality_dashboard?tab=certificates"
|
||||
style="display: inline-block; padding: 10px 20px; background-color: #F59E0B; color: white; text-decoration: none; font-weight: 600; border-radius: 4px;">
|
||||
Open Quality Dashboard →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="fp_notif_cert_awaiting_issuance" model="fp.notification.template">
|
||||
<field name="name">Cert Awaiting Issuance</field>
|
||||
<field name="trigger_event">cert_awaiting_issuance</field>
|
||||
<field name="mail_template_id" ref="fp_mail_template_cert_awaiting_issuance"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 2. Cert Voided — Please Re-Issue (Urgent, #DC2626) -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="fp_mail_template_cert_voided_re_notify" model="mail.template">
|
||||
<field name="name">FP: Cert Voided — Re-Issue</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="subject">⚠️ Job {{ object.display_wo_name or object.name }} CoC voided — please re-issue</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #DC2626; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #DC2626; font-weight: 600; margin-bottom: 8px;">
|
||||
Cert Regression
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">CoC Voided — Please Re-Issue</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.75;">
|
||||
A previously-issued CoC for job
|
||||
<strong t-out="object.display_wo_name or object.name"/>
|
||||
(<t t-out="object.partner_id.name"/>) was voided. The job has
|
||||
slid back to <em>Awaiting Cert</em> and is waiting for re-issuance.
|
||||
</p>
|
||||
<p style="margin: 20px 0;">
|
||||
<a t-attf-href="/odoo/action-fusion_plating_quality.action_fp_quality_dashboard?tab=certificates"
|
||||
style="display: inline-block; padding: 10px 20px; background-color: #DC2626; color: white; text-decoration: none; font-weight: 600; border-radius: 4px;">
|
||||
Open Quality Dashboard →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="fp_notif_cert_voided_re_notify" model="fp.notification.template">
|
||||
<field name="name">Cert Voided — Re-Issue Required</field>
|
||||
<field name="trigger_event">cert_voided_re_notify</field>
|
||||
<field name="mail_template_id" ref="fp_mail_template_cert_voided_re_notify"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -24,6 +24,10 @@ TRIGGER_EVENTS = [
|
||||
('rma_authorised', 'RMA Authorised'), # Sub 12 — RMA lifecycle
|
||||
('rma_received', 'RMA Parts Received'),
|
||||
('rma_resolved', 'RMA Resolved'),
|
||||
# Spec 2026-05-25 — post-shop cert + shipping states
|
||||
('cert_awaiting_issuance', 'Cert Awaiting Issuance'),
|
||||
('cert_voided_re_notify', 'Cert Voided — Please Re-Issue'),
|
||||
('job_shipped', 'Job Shipped (manual mark)'),
|
||||
]
|
||||
|
||||
# Sub 6 — map each trigger event to a communication stream. Contacts on
|
||||
@@ -119,9 +123,17 @@ class FpNotificationTemplate(models.Model):
|
||||
if attachment_ids:
|
||||
attachment_names = self.env['ir.attachment'].browse(attachment_ids).mapped('name')
|
||||
|
||||
# Sub 6 — resolve recipients via the contact-routing helper.
|
||||
# Spec 2026-05-25 — cert authority events go to INTERNAL users
|
||||
# (Manager / QM / Owner), not customer contacts. Replace partner-
|
||||
# based resolution with the group-membership resolver.
|
||||
recipient_emails = []
|
||||
if partner:
|
||||
if trigger_event in ('cert_awaiting_issuance',
|
||||
'cert_voided_re_notify'):
|
||||
authority_users = self._fp_resolve_cert_authority_users(record)
|
||||
recipient_emails = [
|
||||
u.email for u in authority_users if u.email
|
||||
]
|
||||
elif partner:
|
||||
stream = FP_TRIGGER_STREAM.get(trigger_event)
|
||||
if stream:
|
||||
recipient_emails = partner._fp_resolve_notification_recipients(
|
||||
@@ -173,6 +185,33 @@ class FpNotificationTemplate(models.Model):
|
||||
'error_message': str(exc),
|
||||
})
|
||||
|
||||
@api.model
|
||||
def _fp_resolve_cert_authority_users(self, source_record=None):
|
||||
"""Return active, non-share users holding QM | Manager | Owner
|
||||
(transitive via all_group_ids). Per CLAUDE.md Rule 13l, direct
|
||||
user_ids on a group record only catches DIRECT memberships;
|
||||
Owners reach QM authority via the implication chain and would
|
||||
be missed by a naive .user_ids walk.
|
||||
|
||||
Spec 2026-05-25 §Notification recipient resolution.
|
||||
"""
|
||||
gids = []
|
||||
for xmlid in (
|
||||
'fusion_plating.group_fp_quality_manager',
|
||||
'fusion_plating.group_fp_manager',
|
||||
'fusion_plating.group_fp_owner',
|
||||
):
|
||||
grp = self.env.ref(xmlid, raise_if_not_found=False)
|
||||
if grp:
|
||||
gids.append(grp.id)
|
||||
if not gids:
|
||||
return self.env['res.users']
|
||||
return self.env['res.users'].sudo().search([
|
||||
('all_group_ids', 'in', gids),
|
||||
('share', '=', False),
|
||||
('active', '=', True),
|
||||
])
|
||||
|
||||
def _collect_attachments(self, record):
|
||||
"""Return a list of ir.attachment ids to attach to the email based
|
||||
on the template's attach_* flags and the record's context.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Quality (QMS)',
|
||||
'version': '19.0.6.6.6',
|
||||
'version': '19.0.7.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
|
||||
'internal audits, customer specs, document control. CE + EE compatible.',
|
||||
|
||||
@@ -23,6 +23,7 @@ class FpQualityDashboardController(http.Controller):
|
||||
- CAPA: due_date < today AND state not in (effective, closed)
|
||||
- RMA: state='received' for > 5 days (triage past due) OR
|
||||
state in (authorised, shipped_to_us) for > 14 days
|
||||
- Certificate: state='draft' for > 1 day (spec 2026-05-25)
|
||||
"""
|
||||
env = request.env
|
||||
today = fields.Date.context_today(env.user)
|
||||
@@ -33,6 +34,7 @@ class FpQualityDashboardController(http.Controller):
|
||||
Ncr = env['fusion.plating.ncr']
|
||||
Capa = env['fusion.plating.capa']
|
||||
Rma = env['fusion.plating.rma']
|
||||
Cert = env['fp.certificate'] if 'fp.certificate' in env else None
|
||||
|
||||
d3 = fields.Datetime.subtract(now, days=3)
|
||||
d1 = fields.Datetime.subtract(now, days=1)
|
||||
@@ -87,4 +89,12 @@ class FpQualityDashboardController(http.Controller):
|
||||
('create_date', '<', d14),
|
||||
]),
|
||||
},
|
||||
# Spec 2026-05-25 — Certificates tab
|
||||
'certificates': ({
|
||||
'open': Cert.search_count([('state', '=', 'draft')]),
|
||||
'overdue': Cert.search_count([
|
||||
('state', '=', 'draft'),
|
||||
('create_date', '<', d1),
|
||||
]),
|
||||
} if Cert is not None else {'open': 0, 'overdue': 0}),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Battle test — post-shop state machine (awaiting_cert + awaiting_ship).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-25-post-shop-cert-shipping-job-states-plan.md
|
||||
|
||||
Run on entech via:
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"
|
||||
exec(open(\\\"/mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_post_shop_states.py\\\").read())
|
||||
\" | su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\"'"
|
||||
|
||||
The script rolls back at the end so it leaves the DB clean.
|
||||
|
||||
10-step verification:
|
||||
1. Create SO + job with cert-requiring customer
|
||||
2. Walk every step to terminal → assert state='awaiting_cert' (or
|
||||
'awaiting_ship' if no cert required)
|
||||
3. Assert card appears in plant_kanban under 'inspection' / 'shipping'
|
||||
4. Assert activity scheduled on a QM (notification fire is async —
|
||||
skip strict email assertion in this test)
|
||||
5. As a Technician, call cert.action_issue() → assert AccessError
|
||||
6. As a QM, call cert.action_issue() → state='issued', job state→'awaiting_ship'
|
||||
7. Assert card moves to 'shipping' column, activity auto-resolves
|
||||
8. Void the cert → assert state back to 'awaiting_cert'
|
||||
9. Re-create cert + re-issue → 'awaiting_ship' again
|
||||
10. Click button_mark_shipped (as Manager) → state='done', card off board
|
||||
"""
|
||||
from odoo.exceptions import AccessError, UserError
|
||||
|
||||
|
||||
def _assert(cond, label):
|
||||
if cond:
|
||||
print('OK -', label)
|
||||
else:
|
||||
print('FAIL -', label)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
# ---- Setup ----------------------------------------------------------
|
||||
# Find a cert-requiring customer (x_fc_send_coc=True) for the awaiting_cert
|
||||
# path. Falls back to first partner if none.
|
||||
partner = env['res.partner'].search([
|
||||
('x_fc_send_coc', '=', True),
|
||||
('is_company', '=', True),
|
||||
], limit=1)
|
||||
cert_required_path = bool(partner)
|
||||
if not partner:
|
||||
partner = env['res.partner'].search([('is_company', '=', True)], limit=1)
|
||||
_assert(bool(partner), 'partner exists')
|
||||
|
||||
product = env['product.product'].search([], limit=1)
|
||||
_assert(bool(product), 'product exists')
|
||||
|
||||
# Role lookups (transitive via all_group_ids — Owners reach QM via implication)
|
||||
qm_gid = env.ref('fusion_plating.group_fp_quality_manager').id
|
||||
mgr_gid = env.ref('fusion_plating.group_fp_manager').id
|
||||
tech_gid = env.ref('fusion_plating.group_fp_technician').id
|
||||
|
||||
qm = env['res.users'].search([
|
||||
('all_group_ids', 'in', qm_gid),
|
||||
('share', '=', False), ('active', '=', True),
|
||||
], limit=1)
|
||||
mgr = env['res.users'].search([
|
||||
('all_group_ids', 'in', mgr_gid),
|
||||
('share', '=', False), ('active', '=', True),
|
||||
], limit=1)
|
||||
tech = env['res.users'].search([
|
||||
('all_group_ids', 'in', tech_gid),
|
||||
('share', '=', False), ('active', '=', True),
|
||||
], limit=1)
|
||||
_assert(bool(qm), 'QM user exists (via all_group_ids)')
|
||||
_assert(bool(mgr), 'Manager user exists')
|
||||
_assert(bool(tech), 'Technician user exists')
|
||||
|
||||
# ---- 1. Create job in_progress with one final step -----------------
|
||||
job = env['fp.job'].create({
|
||||
'partner_id': partner.id,
|
||||
'product_id': product.id,
|
||||
'qty': 1.0,
|
||||
'state': 'in_progress',
|
||||
})
|
||||
step = env['fp.job.step'].create({
|
||||
'job_id': job.id,
|
||||
'name': 'Final Inspection',
|
||||
'state': 'in_progress',
|
||||
'sequence': 10,
|
||||
})
|
||||
_assert(job.state == 'in_progress', 'job created in_progress')
|
||||
|
||||
# ---- 2. Finish the step → auto-advance -----------------------------
|
||||
# Bypass other gates that aren't relevant here (qty, bake, qc — not
|
||||
# the system under test).
|
||||
ctx = {
|
||||
'fp_skip_required_inputs_gate': True,
|
||||
'fp_skip_signoff_gate': True,
|
||||
'fp_skip_qty_reconcile': True,
|
||||
'fp_skip_bake_gate': True,
|
||||
'fp_skip_qc_gate': True,
|
||||
}
|
||||
step.with_context(**ctx).button_finish()
|
||||
job.invalidate_recordset()
|
||||
expected = 'awaiting_cert' if cert_required_path else 'awaiting_ship'
|
||||
_assert(job.state == expected, f'state→{expected} (got {job.state})')
|
||||
|
||||
# ---- 3. Kanban column placement ------------------------------------
|
||||
from odoo.addons.fusion_plating_shopfloor.controllers import plant_kanban as pk
|
||||
area = pk._resolve_card_area(job)
|
||||
expected_area = 'inspection' if cert_required_path else 'shipping'
|
||||
_assert(area == expected_area, f'card area is {expected_area!r} (got {area!r})')
|
||||
|
||||
# ---- 4. Activity scheduled (only when awaiting_cert path) ----------
|
||||
if cert_required_path:
|
||||
activity_type = env.ref(
|
||||
'fusion_plating_jobs.activity_type_issue_coc',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if activity_type:
|
||||
acts = job.activity_ids.filtered(
|
||||
lambda a: a.activity_type_id == activity_type
|
||||
)
|
||||
_assert(bool(acts), 'Issue-CoC activity scheduled')
|
||||
else:
|
||||
print('SKIP - activity_type_issue_coc not loaded (run with -u first)')
|
||||
|
||||
# ---- 5/6. ACL on cert.action_issue --------------------------------
|
||||
if cert_required_path:
|
||||
cert = env['fp.certificate'].search(
|
||||
[('x_fc_job_id', '=', job.id)], limit=1,
|
||||
)
|
||||
if cert:
|
||||
# Tech tries to issue → AccessError
|
||||
try:
|
||||
cert.with_user(tech).action_issue()
|
||||
_assert(False, 'Technician issue should raise AccessError')
|
||||
except AccessError:
|
||||
print('OK - Technician issue raised AccessError')
|
||||
except UserError as e:
|
||||
# Tech might hit a UserError gate before the ACL check fires —
|
||||
# accept that as "tech blocked" too.
|
||||
print(f'OK - Technician blocked: UserError: {str(e)[:80]}')
|
||||
|
||||
# QM issues — first pre-fill the gates so action_issue can proceed
|
||||
if not cert.spec_reference:
|
||||
cert.spec_reference = 'TEST-SPEC'
|
||||
if not cert.process_description:
|
||||
cert.process_description = 'TEST PROCESS'
|
||||
if not cert.certified_by_id:
|
||||
cert.certified_by_id = qm.id
|
||||
if not cert.contact_partner_id:
|
||||
cert.contact_partner_id = partner.id
|
||||
try:
|
||||
cert.with_user(qm).action_issue()
|
||||
cert.invalidate_recordset()
|
||||
job.invalidate_recordset()
|
||||
_assert(cert.state == 'issued',
|
||||
f'cert.state=issued (got {cert.state})')
|
||||
_assert(job.state == 'awaiting_ship',
|
||||
f'job→awaiting_ship (got {job.state})')
|
||||
print('OK - QM issue succeeded; job advanced')
|
||||
except UserError as e:
|
||||
print(f'SKIP - cert issue failed prerequisite: {str(e)[:120]}')
|
||||
else:
|
||||
print('SKIP - no cert auto-spawned (cert not required path?)')
|
||||
|
||||
# ---- 7. Activity auto-resolved on awaiting_ship -------------------
|
||||
if cert_required_path and job.state == 'awaiting_ship':
|
||||
activity_type = env.ref(
|
||||
'fusion_plating_jobs.activity_type_issue_coc',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if activity_type:
|
||||
acts = job.activity_ids.filtered(
|
||||
lambda a: a.activity_type_id == activity_type
|
||||
)
|
||||
_assert(not acts, 'Issue-CoC activity auto-resolved')
|
||||
|
||||
# ---- 8. Void cert → regress to awaiting_cert ----------------------
|
||||
if cert_required_path and job.state == 'awaiting_ship':
|
||||
issued_certs = env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id), ('state', '=', 'issued'),
|
||||
])
|
||||
if issued_certs:
|
||||
issued_certs[:1].write({'state': 'voided'})
|
||||
job.invalidate_recordset()
|
||||
_assert(job.state == 'awaiting_cert',
|
||||
f'state regressed to awaiting_cert (got {job.state})')
|
||||
print('OK - cert void regressed job state')
|
||||
|
||||
# ---- 9. Re-issue path: create + issue a new cert ------------------
|
||||
if cert_required_path and job.state == 'awaiting_cert':
|
||||
new_cert = env['fp.certificate'].create({
|
||||
'partner_id': partner.id,
|
||||
'certificate_type': 'coc',
|
||||
'x_fc_job_id': job.id,
|
||||
'state': 'draft',
|
||||
'spec_reference': 'TEST-SPEC-2',
|
||||
'process_description': 'TEST PROCESS',
|
||||
'certified_by_id': qm.id,
|
||||
'contact_partner_id': partner.id,
|
||||
})
|
||||
try:
|
||||
new_cert.with_user(qm).action_issue()
|
||||
job.invalidate_recordset()
|
||||
# Need ALL required certs issued for the advance — there may be
|
||||
# a remaining voided cert from step 8 that's still in draft/etc.
|
||||
# Just check that state has moved off awaiting_cert.
|
||||
print(f'OK - re-issue path: job state now {job.state}')
|
||||
except UserError as e:
|
||||
print(f'SKIP - re-issue prerequisite failed: {str(e)[:120]}')
|
||||
|
||||
# ---- 10. Manual Mark Shipped (Manager) ----------------------------
|
||||
if job.state == 'awaiting_ship':
|
||||
job.with_user(mgr).button_mark_shipped()
|
||||
job.invalidate_recordset()
|
||||
_assert(job.state == 'done', f'state→done (got {job.state})')
|
||||
print('OK - Manager Mark Shipped lands done')
|
||||
|
||||
print()
|
||||
print('--- bt_post_shop_states: ALL PASS ---')
|
||||
|
||||
# Leave DB clean — rollback the test data.
|
||||
env.cr.rollback()
|
||||
print('rolled back test data')
|
||||
@@ -13,11 +13,14 @@ import { useService } from "@web/core/utils/hooks";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
const TABS = [
|
||||
{ id: "holds", label: "Holds", model: "fusion.plating.quality.hold", group: "state", domain: [["state", "in", ["on_hold", "under_review"]]] },
|
||||
{ id: "checks", label: "Checks", model: "fusion.plating.quality.check", group: "state", domain: [] },
|
||||
{ id: "ncrs", label: "NCRs", model: "fusion.plating.ncr", group: "stage_id", domain: [["state", "!=", "closed"]] },
|
||||
{ id: "capas", label: "CAPAs", model: "fusion.plating.capa", group: "state", domain: [["state", "not in", ["closed", "effective"]]] },
|
||||
{ id: "rmas", label: "RMAs", model: "fusion.plating.rma", group: "stage_id", domain: [["state", "not in", ["closed", "cancelled"]]] },
|
||||
{ id: "holds", label: "Holds", model: "fusion.plating.quality.hold", group: "state", domain: [["state", "in", ["on_hold", "under_review"]]] },
|
||||
{ id: "checks", label: "Checks", model: "fusion.plating.quality.check", group: "state", domain: [] },
|
||||
{ id: "ncrs", label: "NCRs", model: "fusion.plating.ncr", group: "stage_id", domain: [["state", "!=", "closed"]] },
|
||||
{ id: "capas", label: "CAPAs", model: "fusion.plating.capa", group: "state", domain: [["state", "not in", ["closed", "effective"]]] },
|
||||
{ id: "rmas", label: "RMAs", model: "fusion.plating.rma", group: "stage_id", domain: [["state", "not in", ["closed", "cancelled"]]] },
|
||||
// Spec 2026-05-25 — Certificates tab. QM-owned queue of certs
|
||||
// awaiting issuance; drives the post-shop awaiting_cert workflow.
|
||||
{ id: "certificates", label: "Certificates", model: "fp.certificate", group: "state", domain: [["state", "=", "draft"]] },
|
||||
];
|
||||
|
||||
export class FpQualityDashboard extends Component {
|
||||
@@ -26,8 +29,14 @@ export class FpQualityDashboard extends Component {
|
||||
|
||||
setup() {
|
||||
this.action = useService("action");
|
||||
// Spec 2026-05-25 — honor ?tab=<name> deep-link from the
|
||||
// cert_awaiting_issuance notification email so the QM lands
|
||||
// directly on the Certificates tab.
|
||||
const tabParam = this.props.action?.context?.params?.tab
|
||||
|| this.props.action?.params?.tab;
|
||||
const validTab = TABS.find(t => t.id === tabParam);
|
||||
this.state = useState({
|
||||
activeTab: "ncrs",
|
||||
activeTab: validTab ? validTab.id : "ncrs",
|
||||
counts: TABS.reduce((acc, t) => ({ ...acc, [t.id]: { open: 0, overdue: 0 } }), {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<h2 class="mb-2">Quality Overview</h2>
|
||||
<div class="d-flex gap-4">
|
||||
<div>
|
||||
<div class="o_fp_qd_metric_label">Open across all 5</div>
|
||||
<div class="o_fp_qd_metric_label">Open across all <t t-esc="tabs.length"/></div>
|
||||
<div class="o_fp_qd_metric_value"><t t-esc="totalOpen"/></div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.33.2.0',
|
||||
'version': '19.0.34.0.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
|
||||
'description': """
|
||||
|
||||
@@ -38,11 +38,13 @@ _SORT_PRIORITY = {
|
||||
'no_parts': 1,
|
||||
'bake_due': 2,
|
||||
'awaiting_signoff': 3,
|
||||
'awaiting_cert': 3.5, # spec 2026-05-25 — after awaiting_signoff
|
||||
'awaiting_qc': 4,
|
||||
'ready_mine': 5,
|
||||
'running_mine': 6,
|
||||
'ready': 7,
|
||||
'running': 8,
|
||||
'awaiting_ship': 8.5, # spec 2026-05-25 — after running
|
||||
'idle_warning': 9,
|
||||
'predecessor_locked': 10,
|
||||
'contract_review': 11,
|
||||
@@ -65,13 +67,18 @@ class PlantKanbanController(http.Controller):
|
||||
else env['fp.work.centre'])
|
||||
paired_area = paired.area_kind if paired else None
|
||||
|
||||
# Base domain — only in-flight jobs.
|
||||
# Base domain — in-flight jobs.
|
||||
# 2026-05-24 (spec 2026-05-24-shopfloor-live-step-fix-design.md
|
||||
# Defect 4 / Change 3): done + cancelled jobs drop off the live
|
||||
# board. They stay reachable via smart buttons, the Plating Jobs
|
||||
# backend list, and history reports.
|
||||
# 2026-05-25 (spec post-shop-cert-shipping-job-states): awaiting_cert
|
||||
# + awaiting_ship are included so completed-but-uncertified /
|
||||
# ready-to-ship jobs stay visible in the Final inspection /
|
||||
# Shipping columns.
|
||||
domain = [
|
||||
('state', 'in', ('confirmed', 'in_progress')),
|
||||
('state', 'in', ('confirmed', 'in_progress',
|
||||
'awaiting_cert', 'awaiting_ship')),
|
||||
]
|
||||
filters = filters or {}
|
||||
if filters.get('overdue'):
|
||||
@@ -88,6 +95,11 @@ class PlantKanbanController(http.Controller):
|
||||
)))
|
||||
if filters.get('mine'):
|
||||
domain.append(('card_state', 'in', ('ready_mine', 'running_mine')))
|
||||
# Spec 2026-05-25 — post-shop state filter chips
|
||||
if filters.get('awaiting_cert'):
|
||||
domain.append(('state', '=', 'awaiting_cert'))
|
||||
if filters.get('awaiting_ship'):
|
||||
domain.append(('state', '=', 'awaiting_ship'))
|
||||
if filters.get('fair'):
|
||||
# Match either part-catalog or partner level requires_first_article
|
||||
domain.append('|')
|
||||
@@ -133,6 +145,13 @@ class PlantKanbanController(http.Controller):
|
||||
'on_hold': sum(
|
||||
1 for j in jobs if j.card_state == 'on_hold'
|
||||
),
|
||||
# Spec 2026-05-25 — post-shop state KPIs
|
||||
'awaiting_cert': sum(
|
||||
1 for j in jobs if j.state == 'awaiting_cert'
|
||||
),
|
||||
'awaiting_ship': sum(
|
||||
1 for j in jobs if j.state == 'awaiting_ship'
|
||||
),
|
||||
'overdue': sum(
|
||||
1 for j in jobs
|
||||
if j.date_deadline and j.date_deadline.date() < date.today()
|
||||
@@ -181,6 +200,14 @@ def _resolve_card_area(job):
|
||||
# active step is — the receiver is who acts.
|
||||
if job.card_state == 'no_parts':
|
||||
return 'receiving'
|
||||
# 2026-05-25 (spec post-shop-cert-shipping-job-states): state drives
|
||||
# column for the two post-shop states. The recipe may not even have
|
||||
# a step with inspection / shipping area_kind, but the card belongs
|
||||
# in those columns once the job has cleared all shop steps.
|
||||
if job.state == 'awaiting_cert':
|
||||
return 'inspection'
|
||||
if job.state == 'awaiting_ship':
|
||||
return 'shipping'
|
||||
if job.active_step_id and job.active_step_id.area_kind:
|
||||
return job.active_step_id.area_kind
|
||||
# Orphan fallback — represents a data integrity issue, not a
|
||||
@@ -319,6 +346,11 @@ def _state_chip(card_state, step):
|
||||
return {'label': _('📦 Parts in transit'), 'kind': 'no_parts'}
|
||||
if card_state == 'contract_review':
|
||||
return {'label': _('📋 QA-005 review'), 'kind': 'paperwork'}
|
||||
# Spec 2026-05-25 — post-shop states
|
||||
if card_state == 'awaiting_cert':
|
||||
return {'label': _('🏷️ Awaiting CoC'), 'kind': 'awaiting_cert'}
|
||||
if card_state == 'awaiting_ship':
|
||||
return {'label': _('📦 Ready to ship'), 'kind': 'awaiting_ship'}
|
||||
if card_state == 'done':
|
||||
return {'label': _('✓ Ready for pickup'), 'kind': 'done'}
|
||||
return {'label': '', 'kind': ''}
|
||||
|
||||
@@ -34,6 +34,12 @@ $_plant-noparts-border-hex: #6c757d;
|
||||
$_plant-done-bg-hex: #f0f9f4;
|
||||
$_plant-done-border-hex: #28a745;
|
||||
|
||||
// Spec 2026-05-25 — post-shop states
|
||||
$_plant-awaiting-cert-bg-hex: #fff3cd;
|
||||
$_plant-awaiting-cert-border-hex: #ff9800;
|
||||
$_plant-awaiting-ship-bg-hex: #d1f1d4;
|
||||
$_plant-awaiting-ship-border-hex: #2e7d32;
|
||||
|
||||
// === Dark-mode overrides (compile-time branch per project rule) ===
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_plant-bg-hex: #1a1d21 !global;
|
||||
@@ -51,6 +57,12 @@ $_plant-done-border-hex: #28a745;
|
||||
$_plant-locked-bg-hex: #2d3138 !global;
|
||||
$_plant-noparts-bg-hex: #2d3138 !global;
|
||||
$_plant-done-bg-hex: #14281a !global;
|
||||
|
||||
// Spec 2026-05-25 — post-shop states (dark)
|
||||
$_plant-awaiting-cert-bg-hex: #3a2f15 !global;
|
||||
$_plant-awaiting-cert-border-hex: #ffb74d !global;
|
||||
$_plant-awaiting-ship-bg-hex: #1a2d1f !global;
|
||||
$_plant-awaiting-ship-border-hex: #66bb6a !global;
|
||||
}
|
||||
|
||||
// === CSS-custom-property wrappers so future themes can override ===
|
||||
@@ -78,3 +90,9 @@ $plant-noparts-bg: var(--fp-plant-noparts-bg, $_plant-noparts-bg-hex);
|
||||
$plant-noparts-border: var(--fp-plant-noparts-border, $_plant-noparts-border-hex);
|
||||
$plant-done-bg: var(--fp-plant-done-bg, $_plant-done-bg-hex);
|
||||
$plant-done-border: var(--fp-plant-done-border, $_plant-done-border-hex);
|
||||
|
||||
// Spec 2026-05-25 — post-shop states
|
||||
$plant-awaiting-cert-bg: var(--fp-plant-awaiting-cert-bg, $_plant-awaiting-cert-bg-hex);
|
||||
$plant-awaiting-cert-border: var(--fp-plant-awaiting-cert-border, $_plant-awaiting-cert-border-hex);
|
||||
$plant-awaiting-ship-bg: var(--fp-plant-awaiting-ship-bg, $_plant-awaiting-ship-bg-hex);
|
||||
$plant-awaiting-ship-border: var(--fp-plant-awaiting-ship-border, $_plant-awaiting-ship-border-hex);
|
||||
|
||||
@@ -66,6 +66,17 @@
|
||||
border-left: 4px solid $plant-done-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
// Spec 2026-05-25 — post-shop states
|
||||
&.state-awaiting_cert {
|
||||
background: $plant-awaiting-cert-bg;
|
||||
border-left: 4px solid $plant-awaiting-cert-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.state-awaiting_ship {
|
||||
background: $plant-awaiting-ship-bg;
|
||||
border-left: 4px solid $plant-awaiting-ship-border;
|
||||
padding-left: 7px;
|
||||
}
|
||||
&.overdue:not(.mine):not(.state-on_hold):not(.state-bake_due) {
|
||||
border-left: 4px solid $plant-hold-border;
|
||||
padding-left: 7px;
|
||||
|
||||
@@ -47,6 +47,17 @@
|
||||
kind="'urgent'"
|
||||
active="!!state.filters.on_hold"
|
||||
onClick="() => this.toggleFilter('on_hold')"/>
|
||||
<!-- Spec 2026-05-25 — post-shop state tiles -->
|
||||
<FpKpiTile value="state.data.kpis.awaiting_cert"
|
||||
label="'Awaiting CoC'"
|
||||
kind="'warn'"
|
||||
active="!!state.filters.awaiting_cert"
|
||||
onClick="() => this.toggleFilter('awaiting_cert')"/>
|
||||
<FpKpiTile value="state.data.kpis.awaiting_ship"
|
||||
label="'Ready to Ship'"
|
||||
kind="'good'"
|
||||
active="!!state.filters.awaiting_ship"
|
||||
onClick="() => this.toggleFilter('awaiting_ship')"/>
|
||||
<FpKpiTile value="state.data.kpis.overdue"
|
||||
label="'Overdue'"
|
||||
kind="'urgent'"
|
||||
@@ -78,6 +89,13 @@
|
||||
<FpFilterChip label="'FAIR'"
|
||||
active="!!state.filters.fair"
|
||||
onToggle="() => this.toggleFilter('fair')"/>
|
||||
<!-- Spec 2026-05-25 — post-shop state chips -->
|
||||
<FpFilterChip label="'Awaiting CoC'"
|
||||
active="!!state.filters.awaiting_cert"
|
||||
onToggle="() => this.toggleFilter('awaiting_cert')"/>
|
||||
<FpFilterChip label="'Ready to Ship'"
|
||||
active="!!state.filters.awaiting_ship"
|
||||
onToggle="() => this.toggleFilter('awaiting_ship')"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user