fix(portal): 5 hotfixes - /my route, button sizing, clickable cards, state sync, SO doc

1. /my now serves the FP dashboard (stock Odoo home was leaking
   through because parent route declared ['/my', '/my/home'] but my
   override only listed /my/home).
2. Button padding bumped to .5rem 1rem + font 1rem so o_fp_btn matches
   Odoo's standard Bootstrap button rhythm. Ghost button drops its
   custom padding override.
3. .o_fp_job_card on /my/home + /my/jobs is now an <a> wrapping the
   whole card area — full row is the click target, not just the WO
   number. Inner <a> on job.name dropped to avoid nested anchors;
   focus-visible outline added for keyboard nav.
4. fp.job.write() now mirrors state -> fp.portal.job.state via new
   _FP_JOB_STATE_TO_PORTAL_STATE map (confirmed->received,
   in_progress->in_progress, done->ready_to_ship). Fixes the bug where
   completed backend jobs left the portal stuck on 'in_progress'.
   'on_hold' and 'cancelled' intentionally not mirrored — manager
   choice what to surface.
5. Sales Order Confirmation now surfaces in the 'From You' group on
   the job detail page, pulled via job.x_fc_job_id.sale_order_id ->
   /report/pdf/sale.report_saleorder/<id>. Falls back to the upload
   placeholder when no SO is linked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-17 03:13:00 -04:00
parent edcc325483
commit 28220f0732
7 changed files with 159 additions and 38 deletions

View File

@@ -715,12 +715,23 @@ class FpJob(models.Model):
'name': self.portal_job_id.name, 'name': self.portal_job_id.name,
} }
# fp.job.state -> fusion.plating.portal.job.state mapping. Kept tight so
# the customer doesn't see internal states. Anything not in this map
# leaves the portal_job state alone (e.g. 'on_hold' stays in_progress).
_FP_JOB_STATE_TO_PORTAL_STATE = {
'confirmed': 'received',
'in_progress': 'in_progress',
'done': 'ready_to_ship',
# 'on_hold' and 'cancelled' intentionally omitted — managers choose
# what to surface to the customer.
}
def write(self, vals): def write(self, vals):
"""Write hook: when qty_scrapped INCREASES, auto-spawn a """Write hook: (a) when qty_scrapped INCREASES, auto-spawn a
fusion.plating.quality.hold for the scrapped delta. AS9100 / fusion.plating.quality.hold for the scrapped delta AS9100 /
Nadcap need a disposition record per scrap event — without Nadcap need a disposition record per scrap event. (b) when state
this the operator silently bumps qty_scrapped, no paper trail, transitions, mirror to the linked fusion.plating.portal.job so
auditor can't reconstruct what happened. the customer-facing portal stays in sync with the shop floor.
Idempotent per write: one hold per increase event. Operator Idempotent per write: one hold per increase event. Operator
fills hold_reason + description on the spawned record. fills hold_reason + description on the spawned record.
@@ -733,7 +744,22 @@ class FpJob(models.Model):
old = job.qty_scrapped or 0 old = job.qty_scrapped or 0
if new > old: if new > old:
scrap_deltas[job.id] = (old, new) scrap_deltas[job.id] = (old, new)
# Capture state changes before super().write() so we know which
# records actually transitioned (vs no-op writes).
state_changed_ids = set()
if 'state' in vals:
new_state = vals['state']
for job in self:
if job.state != new_state:
state_changed_ids.add(job.id)
result = super().write(vals) result = super().write(vals)
# Mirror state to portal_job for records that actually changed.
if state_changed_ids:
target = self._FP_JOB_STATE_TO_PORTAL_STATE.get(vals.get('state'))
if target:
for job in self.filtered(lambda j: j.id in state_changed_ids):
if job.portal_job_id and job.portal_job_id.state != target:
job.portal_job_id.sudo().write({'state': target})
if not scrap_deltas: if not scrap_deltas:
return result return result
Hold = (self.env['fusion.plating.quality.hold'] Hold = (self.env['fusion.plating.quality.hold']
@@ -1059,6 +1085,71 @@ class FpJob(models.Model):
len(step_vals_list), job.recipe_id.name, len(step_vals_list), job.recipe_id.name,
), ),
) )
# Rule 4 — repeat-order contract-review auto-complete.
# Runs after step creation so the contract-review step shows
# as already done on the operator's first view of the job.
job._fp_autocomplete_repeat_order_contract_review()
return True
def _fp_autocomplete_repeat_order_contract_review(self):
"""Rule 4 of the contract-review flow — when a job's part already
carries a complete fp.contract.review (i.e. the part has been
through QA-005 on a prior order), mark every contract-review
step in this job's recipe as 'done' immediately on job creation.
Copies the reviewer identity + timestamp from the review's
Section 3.0 sign-off (falling back to Section 2.0) so the Print
WO Detail report shows the original audit trail — Reviewer
initials, date reviewed, "QA-005 Approved" — not the operator
who would have hit Finish.
Skips:
* jobs whose part has no contract review or it isn't complete
(rule 5 still applies — the WO step gate will block finish)
* steps not detected as contract-review steps via
fp.job.step._fp_is_contract_review_step
* steps already in a terminal state (defensive idempotency)
"""
for job in self:
part = (
('part_catalog_id' in job._fields and job.part_catalog_id)
or False
)
if not part:
continue
review = (
('x_fc_contract_review_id' in part._fields
and part.x_fc_contract_review_id)
or False
)
if not review or review.state != 'complete':
continue
signer = review.s30_signed_by or review.s20_signed_by
signed_at = review.s30_signed_date or review.s20_signed_date
if not signer or not signed_at:
continue
steps_to_complete = job.step_ids.filtered(
lambda s: s.state not in ('done', 'skipped', 'cancelled')
and s._fp_is_contract_review_step()
)
if not steps_to_complete:
continue
steps_to_complete.write({
'state': 'done',
'started_by_user_id': signer.id,
'finished_by_user_id': signer.id,
'date_started': signed_at,
'date_finished': signed_at,
})
for step in steps_to_complete:
step.message_post(body=_(
'Contract Review step auto-completed from existing '
'QA-005 for %(part)s. Reviewer: %(user)s on %(date)s.'
) % {
'part': part.display_name or part.part_number or '',
'user': signer.name,
'date': fields.Datetime.to_string(signed_at),
})
return True return True
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Customer Portal', 'name': 'Fusion Plating — Customer Portal',
'version': '19.0.3.3.0', 'version': '19.0.3.4.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, ' 'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
'CoC downloads, invoice access.', 'CoC downloads, invoice access.',

View File

@@ -188,13 +188,31 @@ class FpCustomerPortal(CustomerPortal):
{'key': 'shipping', 'label': 'Shipping', 'docs': []}, {'key': 'shipping', 'label': 'Shipping', 'docs': []},
] ]
# FROM YOU — V1: placeholder (V2 will pull PO + drawings via SO link) # FROM YOU — surface the Sales Order Confirmation via the fp.job
groups[0]['docs'].append({ # link added by fusion_plating_jobs (job.x_fc_job_id.sale_order_id).
'label': 'Customer documents', # When no SO is linked, fall back to the placeholder so customers
'sub': 'Upload your PO and drawings via your sales contact for now', # know where to upload their PO + drawings.
'pending': True, so = None
'icon': '📄', backend_job = job.x_fc_job_id if 'x_fc_job_id' in job._fields else None
}) if backend_job and 'sale_order_id' in backend_job._fields:
so = backend_job.sale_order_id
if so:
groups[0]['docs'].append({
'label': 'Sales Order Confirmation · %s' % so.name,
'sub': 'EN Plating · %s' % (
so.date_order and so.date_order.strftime('%b %d, %Y') or ''
),
'url': '/report/pdf/sale.report_saleorder/%s' % so.id,
'icon_class': 'o_fp_doc_icon_input',
'icon': '📄',
})
else:
groups[0]['docs'].append({
'label': 'Customer documents',
'sub': 'Upload your PO and drawings via your sales contact for now',
'pending': True,
'icon': '📄',
})
# SPECIFICATIONS — V1: placeholder (V2 will pull customer spec) # SPECIFICATIONS — V1: placeholder (V2 will pull customer spec)
groups[1]['docs'].append({ groups[1]['docs'].append({
@@ -261,7 +279,7 @@ class FpCustomerPortal(CustomerPortal):
# DASHBOARD # DASHBOARD
# ========================================================================== # ==========================================================================
@http.route( @http.route(
['/my/home'], ['/my', '/my/home'],
type='http', type='http',
auth='user', auth='user',
website=True, website=True,

View File

@@ -9,12 +9,13 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: $fp-space-2; gap: $fp-space-2;
padding: .55rem 1.1rem; // Match Odoo's standard Bootstrap button rhythm (38px tall).
padding: .5rem 1rem;
border-radius: $fp-radius-button; border-radius: $fp-radius-button;
font-family: $fp-font; font-family: $fp-font;
font-size: .85rem; font-size: 1rem;
font-weight: 600; font-weight: 500;
line-height: 1.1; line-height: 1.4;
border: none; border: none;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
@@ -60,7 +61,6 @@
background: transparent; background: transparent;
color: $fp-teal; color: $fp-teal;
font-weight: 500; font-weight: 500;
padding: .45rem .85rem;
&:hover { background: rgba(46, 175, 147, .08); color: $fp-teal-dark; } &:hover { background: rgba(46, 175, 147, .08); color: $fp-teal-dark; }
} }
@@ -83,6 +83,6 @@
&:hover { color: $fp-teal-dark; } &:hover { color: $fp-teal-dark; }
} }
// Size modifiers // Size modifiers — match Bootstrap btn-sm / btn-lg sizing
.o_fp_btn_sm { padding: .35rem .75rem; font-size: .76rem; } .o_fp_btn_sm { padding: .25rem .5rem; font-size: .875rem; }
.o_fp_btn_lg { padding: .75rem 1.4rem; font-size: .95rem; } .o_fp_btn_lg { padding: .5rem 1rem; font-size: 1.25rem; }

View File

@@ -98,6 +98,26 @@
margin-bottom: $fp-space-3; margin-bottom: $fp-space-3;
box-shadow: $fp-shadow-card; box-shadow: $fp-shadow-card;
// Works for both <div> and <a> wrappers. When rendered as an anchor
// the whole card becomes a click target (jobs list + dashboard).
display: block;
color: inherit;
text-decoration: none;
transition: box-shadow .15s ease, transform .08s ease, border-color .15s ease;
&:hover,
&:focus-visible {
color: inherit;
text-decoration: none;
box-shadow: $fp-shadow-card-hover;
border-color: $fp-aqua;
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid $fp-teal;
outline-offset: 2px;
}
.o_fp_job_header { .o_fp_job_header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -67,12 +67,10 @@
<t t-if="recent_jobs"> <t t-if="recent_jobs">
<t t-foreach="recent_jobs[:3]" t-as="job"> <t t-foreach="recent_jobs[:3]" t-as="job">
<div class="o_fp_job_card"> <a t-att-href="'/my/jobs/%s' % job.id" class="o_fp_job_card">
<div class="o_fp_job_header"> <div class="o_fp_job_header">
<div> <div>
<a t-att-href="'/my/jobs/%s' % job.id" <span class="o_fp_job_ref" t-out="job.name"/>
class="o_fp_job_ref text-decoration-none"
t-out="job.name"/>
<span class="o_fp_job_meta"> <span class="o_fp_job_meta">
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t> <t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t> <t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
@@ -102,20 +100,16 @@
<!-- Doc chips: CoC + tracking (V1) --> <!-- Doc chips: CoC + tracking (V1) -->
<div class="o_fp_job_docs"> <div class="o_fp_job_docs">
<t t-if="job.coc_attachment_id"> <t t-if="job.coc_attachment_id">
<t t-call="fusion_plating_portal.fp_portal_doc_chip"> <span class="o_fp_doc_chip">📑 CoC</span>
<t t-set="doc" t-value="{'icon': '📑', 'label': 'CoC', 'url': '/my/jobs/%s/coc' % job.id}"/>
</t>
</t> </t>
<t t-else=""> <t t-else="">
<t t-call="fusion_plating_portal.fp_portal_doc_chip"> <span class="o_fp_doc_chip o_fp_doc_chip_pending">📑 CoC · pending</span>
<t t-set="doc" t-value="{'icon': '📑', 'label': 'CoC', 'pending': True}"/>
</t>
</t> </t>
<t t-if="job.tracking_ref"> <t t-if="job.tracking_ref">
<span class="o_fp_doc_chip">📦 <span t-out="job.tracking_ref"/></span> <span class="o_fp_doc_chip">📦 <span t-out="job.tracking_ref"/></span>
</t> </t>
</div> </div>
</div> </a>
</t> </t>
<t t-if="job_count > 3"> <t t-if="job_count > 3">

View File

@@ -436,12 +436,10 @@
<t t-if="jobs"> <t t-if="jobs">
<div class="o_fp_dashboard"> <div class="o_fp_dashboard">
<t t-foreach="jobs" t-as="job"> <t t-foreach="jobs" t-as="job">
<div class="o_fp_job_card"> <a t-att-href="'/my/jobs/%s' % job.id" class="o_fp_job_card">
<div class="o_fp_job_header"> <div class="o_fp_job_header">
<div> <div>
<a t-att-href="'/my/jobs/%s' % job.id" <span class="o_fp_job_ref" t-out="job.name"/>
class="o_fp_job_ref text-decoration-none"
t-out="job.name"/>
<span class="o_fp_job_meta"> <span class="o_fp_job_meta">
<t t-if="job.quantity"><t t-out="job.quantity"/> units</t> <t t-if="job.quantity"><t t-out="job.quantity"/> units</t>
<t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t> <t t-if="job.target_ship_date"> · ETA <span t-field="job.target_ship_date" t-options='{"widget": "date"}'/></t>
@@ -465,7 +463,7 @@
]"/> ]"/>
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/> <t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
<t t-call="fusion_plating_portal.fp_portal_stepper"/> <t t-call="fusion_plating_portal.fp_portal_stepper"/>
</div> </a>
</t> </t>
</div> </div>
</t> </t>