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:
@@ -715,12 +715,23 @@ class FpJob(models.Model):
|
||||
'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):
|
||||
"""Write hook: when qty_scrapped INCREASES, auto-spawn a
|
||||
fusion.plating.quality.hold for the scrapped delta. AS9100 /
|
||||
Nadcap need a disposition record per scrap event — without
|
||||
this the operator silently bumps qty_scrapped, no paper trail,
|
||||
auditor can't reconstruct what happened.
|
||||
"""Write hook: (a) when qty_scrapped INCREASES, auto-spawn a
|
||||
fusion.plating.quality.hold for the scrapped delta — AS9100 /
|
||||
Nadcap need a disposition record per scrap event. (b) when state
|
||||
transitions, mirror to the linked fusion.plating.portal.job so
|
||||
the customer-facing portal stays in sync with the shop floor.
|
||||
|
||||
Idempotent per write: one hold per increase event. Operator
|
||||
fills hold_reason + description on the spawned record.
|
||||
@@ -733,7 +744,22 @@ class FpJob(models.Model):
|
||||
old = job.qty_scrapped or 0
|
||||
if new > old:
|
||||
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)
|
||||
# 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:
|
||||
return result
|
||||
Hold = (self.env['fusion.plating.quality.hold']
|
||||
@@ -1059,6 +1085,71 @@ class FpJob(models.Model):
|
||||
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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Customer Portal',
|
||||
'version': '19.0.3.3.0',
|
||||
'version': '19.0.3.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
|
||||
'CoC downloads, invoice access.',
|
||||
|
||||
@@ -188,13 +188,31 @@ class FpCustomerPortal(CustomerPortal):
|
||||
{'key': 'shipping', 'label': 'Shipping', 'docs': []},
|
||||
]
|
||||
|
||||
# FROM YOU — V1: placeholder (V2 will pull PO + drawings via SO link)
|
||||
groups[0]['docs'].append({
|
||||
'label': 'Customer documents',
|
||||
'sub': 'Upload your PO and drawings via your sales contact for now',
|
||||
'pending': True,
|
||||
'icon': '📄',
|
||||
})
|
||||
# FROM YOU — surface the Sales Order Confirmation via the fp.job
|
||||
# link added by fusion_plating_jobs (job.x_fc_job_id.sale_order_id).
|
||||
# When no SO is linked, fall back to the placeholder so customers
|
||||
# know where to upload their PO + drawings.
|
||||
so = None
|
||||
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)
|
||||
groups[1]['docs'].append({
|
||||
@@ -261,7 +279,7 @@ class FpCustomerPortal(CustomerPortal):
|
||||
# DASHBOARD
|
||||
# ==========================================================================
|
||||
@http.route(
|
||||
['/my/home'],
|
||||
['/my', '/my/home'],
|
||||
type='http',
|
||||
auth='user',
|
||||
website=True,
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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;
|
||||
font-family: $fp-font;
|
||||
font-size: .85rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
@@ -60,7 +61,6 @@
|
||||
background: transparent;
|
||||
color: $fp-teal;
|
||||
font-weight: 500;
|
||||
padding: .45rem .85rem;
|
||||
&:hover { background: rgba(46, 175, 147, .08); color: $fp-teal-dark; }
|
||||
}
|
||||
|
||||
@@ -83,6 +83,6 @@
|
||||
&:hover { color: $fp-teal-dark; }
|
||||
}
|
||||
|
||||
// Size modifiers
|
||||
.o_fp_btn_sm { padding: .35rem .75rem; font-size: .76rem; }
|
||||
.o_fp_btn_lg { padding: .75rem 1.4rem; font-size: .95rem; }
|
||||
// Size modifiers — match Bootstrap btn-sm / btn-lg sizing
|
||||
.o_fp_btn_sm { padding: .25rem .5rem; font-size: .875rem; }
|
||||
.o_fp_btn_lg { padding: .5rem 1rem; font-size: 1.25rem; }
|
||||
|
||||
@@ -98,6 +98,26 @@
|
||||
margin-bottom: $fp-space-3;
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -67,12 +67,10 @@
|
||||
|
||||
<t t-if="recent_jobs">
|
||||
<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>
|
||||
<a t-att-href="'/my/jobs/%s' % job.id"
|
||||
class="o_fp_job_ref text-decoration-none"
|
||||
t-out="job.name"/>
|
||||
<span class="o_fp_job_ref" t-out="job.name"/>
|
||||
<span class="o_fp_job_meta">
|
||||
<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>
|
||||
@@ -102,20 +100,16 @@
|
||||
<!-- Doc chips: CoC + tracking (V1) -->
|
||||
<div class="o_fp_job_docs">
|
||||
<t t-if="job.coc_attachment_id">
|
||||
<t t-call="fusion_plating_portal.fp_portal_doc_chip">
|
||||
<t t-set="doc" t-value="{'icon': '📑', 'label': 'CoC', 'url': '/my/jobs/%s/coc' % job.id}"/>
|
||||
</t>
|
||||
<span class="o_fp_doc_chip">📑 CoC</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-call="fusion_plating_portal.fp_portal_doc_chip">
|
||||
<t t-set="doc" t-value="{'icon': '📑', 'label': 'CoC', 'pending': True}"/>
|
||||
</t>
|
||||
<span class="o_fp_doc_chip o_fp_doc_chip_pending">📑 CoC · pending</span>
|
||||
</t>
|
||||
<t t-if="job.tracking_ref">
|
||||
<span class="o_fp_doc_chip">📦 <span t-out="job.tracking_ref"/></span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</t>
|
||||
|
||||
<t t-if="job_count > 3">
|
||||
|
||||
@@ -436,12 +436,10 @@
|
||||
<t t-if="jobs">
|
||||
<div class="o_fp_dashboard">
|
||||
<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>
|
||||
<a t-att-href="'/my/jobs/%s' % job.id"
|
||||
class="o_fp_job_ref text-decoration-none"
|
||||
t-out="job.name"/>
|
||||
<span class="o_fp_job_ref" t-out="job.name"/>
|
||||
<span class="o_fp_job_meta">
|
||||
<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>
|
||||
@@ -465,7 +463,7 @@
|
||||
]"/>
|
||||
<t t-set="active_state" t-value="'warn' if job.state == 'quality_check' else 'normal'"/>
|
||||
<t t-call="fusion_plating_portal.fp_portal_stepper"/>
|
||||
</div>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
Reference in New Issue
Block a user