Files
Odoo-Modules/fusion_plating/fusion_plating_portal/controllers/portal.py
gsinghpal b27f68b8d5 feat(portal): real-time search + filter pills on 4 FP list pages
Replaces the tab nav / portal.portal_searchbar on the 4 FP list
pages with the new fp_portal_list_controls macro (filter pills +
search input + sort dropdown) and drops portal_pager in favour of
client-side filtering of up to 500 records:

- Quote Requests (/my/quote_requests):
    filters: All / Active / Converted / Declined
    sorts:   Newest / Reference / Status
    extra search fields: contact_name, contact_email, line.part_number,
                         line.description, line.product_id.default_code

- Work Orders (/my/jobs, cards layout):
    filters: All / Active / Ready to Ship / Complete
    sorts:   Newest / Reference / Status
    extra search fields per card: part_catalog.part_number, part_catalog.name,
                                  sale_order.name, sale_order.client_order_ref,
                                  job.notes

- Certifications (/my/certifications):
    no filters (all rows are terminal CoC jobs)
    sorts:   Newest / Reference
    extra search fields: part name, processes (already in card text)

- Packing Slips / Deliveries (/my/deliveries):
    no filters (all rows are state=done)
    sorts:   Newest / Reference
    adds a visible Origin column (sale order ref) so customers can
    locate a slip by the SO it came from

Each route accepts ?filter_state=... and ?sortby=... query params,
returns up to 500 records, and passes result_total + clipped to the
template so the macro can render a "showing latest 500 of N" notice
when the cap is hit.

Hidden <td class="d-none"> cells inside each row carry extra terms
that aren't displayed but are matched by the JS textContent scan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:06:18 -04:00

1396 lines
56 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import base64
import json
from datetime import datetime, time as dt_time
from odoo import _, http
from odoo.exceptions import AccessError, MissingError
from odoo.http import request
from odoo.tools import formatLang
from odoo.addons.portal.controllers.portal import (
CustomerPortal,
pager as portal_pager,
)
class FpCustomerPortal(CustomerPortal):
"""Customer portal extension for Fusion Plating.
Adds a rich dashboard, Quote Requests with part lines, Jobs with
segmented progress, Purchase Orders, Invoices, Shipping, and
Certifications sections.
"""
# ==========================================================================
# Portal Home -- counters
# ==========================================================================
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
if 'fp_quote_request_count' in counters:
values['fp_quote_request_count'] = request.env[
'fusion.plating.quote.request'
].search_count([('partner_id', 'child_of', commercial.id)])
if 'fp_portal_job_count' in counters:
values['fp_portal_job_count'] = request.env[
'fusion.plating.portal.job'
].search_count([('partner_id', 'child_of', commercial.id)])
if 'fp_purchase_order_count' in counters:
values['fp_purchase_order_count'] = request.env[
'sale.order'
].sudo().search_count([
('partner_id', 'child_of', commercial.id),
('state', '=', 'sale'),
])
if 'fp_invoice_count' in counters:
values['fp_invoice_count'] = request.env[
'account.move'
].sudo().search_count([
('partner_id', 'child_of', commercial.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
])
if 'fp_delivery_count' in counters:
values['fp_delivery_count'] = request.env[
'stock.picking'
].sudo().search_count([
('partner_id', 'child_of', commercial.id),
('picking_type_code', '=', 'outgoing'),
('state', '=', 'done'),
])
if 'fp_certification_count' in counters:
values['fp_certification_count'] = request.env[
'fusion.plating.portal.job'
].search_count([
('partner_id', 'child_of', commercial.id),
('coc_attachment_id', '!=', False),
])
return values
# ==========================================================================
# Helpers
# ==========================================================================
def _fp_quote_request_get_page_view_values(self, quote_request, access_token, **kwargs):
values = {
'page_name': 'fp_quote_request',
'quote_request': quote_request,
}
return self._get_page_view_values(
quote_request,
access_token,
values,
'my_fp_quote_requests_history',
False,
**kwargs,
)
def _fp_portal_job_get_page_view_values(self, job, access_token, **kwargs):
values = {
'page_name': 'fp_portal_job',
'job': job,
}
return self._get_page_view_values(
job,
access_token,
values,
'my_fp_jobs_history',
False,
**kwargs,
)
def _fp_get_partner_domain(self):
"""Return base domain filtering to commercial partner tree."""
partner = request.env.user.partner_id
return partner.commercial_partner_id
# ==========================================================================
# Sidebar — items + active-state resolution
# ==========================================================================
# Sidebar item structure: list of dicts with `type` = 'item' | 'section_label'.
# Items have label / url / icon / key. Key matches either a page_name set by
# an FP route OR a URL prefix for Odoo default pages.
_FP_SIDEBAR_LAYOUT = [
{'type': 'item', 'key': 'fp_dashboard', 'label': 'Dashboard', 'icon': '🏠', 'url': '/my/home'},
{'type': 'section_label', 'label': 'Activity'},
{'type': 'item', 'key': 'fp_quote_requests', 'label': 'Quote Requests', 'icon': '📄', 'url': '/my/quote_requests'},
{'type': 'item', 'key': 'fp_configurator', 'label': 'Get a Quote', 'icon': '+', 'url': '/my/configurator'},
{'type': 'item', 'key': 'odoo_orders', 'label': 'Purchase Orders', 'icon': '🛒', 'url': '/my/orders'},
{'type': 'item', 'key': 'fp_jobs', 'label': 'Work Orders', 'icon': '⚙️', 'url': '/my/jobs'},
{'type': 'section_label', 'label': 'Documents'},
{'type': 'item', 'key': 'fp_certifications', 'label': 'Certifications', 'icon': '📑', 'url': '/my/certifications'},
{'type': 'item', 'key': 'fp_deliveries', 'label': 'Packing Slips', 'icon': '📦', 'url': '/my/deliveries'},
{'type': 'item', 'key': 'fp_account_summary','label': 'Account Summary', 'icon': '💰', 'url': '/my/account_summary'},
{'type': 'section_label', 'label': 'Account'},
{'type': 'item', 'key': 'odoo_account', 'label': 'Profile', 'icon': '👤', 'url': '/my/account'},
]
# Map either a page_name (set by FP routes) OR a URL prefix
# (for Odoo defaults that don't set page_name) to a sidebar item key.
_FP_PAGE_NAME_TO_SIDEBAR_KEY = {
'fp_dashboard': 'fp_dashboard',
'fp_quote_requests': 'fp_quote_requests',
'fp_quote_request': 'fp_quote_requests',
'fp_configurator': 'fp_configurator',
'fp_jobs': 'fp_jobs',
'fp_portal_job': 'fp_jobs',
'fp_certifications': 'fp_certifications',
'fp_deliveries': 'fp_deliveries',
'fp_account_summary': 'fp_account_summary',
}
_FP_URL_PREFIX_TO_SIDEBAR_KEY = [
# Order matters — first match wins, so list longer prefixes first.
('/my/orders', 'odoo_orders'),
('/my/quotes', 'odoo_orders'), # /my/quotes is also sale_portal
('/my/invoices', 'fp_account_summary'),
('/my/account_summary', 'fp_account_summary'),
('/my/account', 'odoo_account'),
('/my/security', 'odoo_account'),
('/my/home', 'fp_dashboard'),
('/my', 'fp_dashboard'), # /my (no trailing) -> dashboard
]
def _fp_resolve_active_sidebar_key(self, url, page_name):
"""Resolve which sidebar item should be marked active for this request."""
if page_name and page_name in self._FP_PAGE_NAME_TO_SIDEBAR_KEY:
return self._FP_PAGE_NAME_TO_SIDEBAR_KEY[page_name]
if url:
for prefix, key in self._FP_URL_PREFIX_TO_SIDEBAR_KEY:
if url.startswith(prefix):
return key
return None
def _fp_sidebar_items(self, url, page_name):
"""Return the sidebar item list with the right item marked active."""
active_key = self._fp_resolve_active_sidebar_key(url, page_name)
out = []
for entry in self._FP_SIDEBAR_LAYOUT:
if entry.get('type') == 'item':
copy = dict(entry)
copy['active'] = (active_key == entry['key'])
out.append(copy)
else:
out.append(entry)
return out
def _prepare_portal_layout_values(self):
values = super()._prepare_portal_layout_values()
# Resolve current URL + page_name for sidebar active-state
url = request.httprequest.path if request else ''
page_name = values.get('page_name')
values['fp_sidebar_items'] = self._fp_sidebar_items(url, page_name)
# Partner display name for the sidebar header
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
values['fp_partner_display_name'] = commercial.name or partner.name
return values
# ==========================================================================
# Customer-visible stage timeline (detail page)
# ==========================================================================
# 5 customer-facing stages aligned with the dashboard stepper.
# Each entry: (label, timestamp_field_name_on_fp_portal_job).
# Inspected and Plating share `in_progress_started_at` — when state moves
# away from 'received' it means inspection finished and plating started.
_FP_STAGES = [
('Received', 'received_at'),
('Inspected', 'in_progress_started_at'),
('Plating', 'in_progress_started_at'),
('QC', 'qc_started_at'),
('Shipped', 'shipped_at'),
]
# State -> active step index (matches the dashboard stepper logic in
# fp_portal_dashboard.xml so dashboard and detail page agree).
_FP_STATE_TO_STEP_IDX = {
'received': 0,
'in_progress': 2,
'quality_check': 3,
'ready_to_ship': 4,
'shipped': 5,
'complete': 5,
}
def _fp_get_stage_timeline(self, job):
"""Build a 5-entry timeline for the detail-page vertical view.
Returns a list of dicts in stage order. Each dict has:
label, status ('done'|'active'|'pending'), started_at (datetime|None),
time_label (formatted string), notes (str).
Data sourcing per stage:
1. Prefer the real per-stage Datetime field (Task 16 write-hook).
2. Fall back to the existing Date field for Received / Shipped.
3. For middle stages on records that pre-date the hook, linearly
interpolate between received_at and now() across the done stages
so the customer sees a populated timeline instead of empty rows.
Records created post-hook never hit the interpolation branch.
"""
state_idx = self._FP_STATE_TO_STEP_IDX.get(job.state, 0)
# Baseline datetime for interpolation — prefer the precise received_at
# but fall through to received_date (Date) converted to midnight.
baseline = job.received_at
if not baseline and job.received_date:
baseline = datetime.combine(job.received_date, dt_time.min)
now = datetime.now()
out = []
for i, (label, ts_field) in enumerate(self._FP_STAGES):
if i < state_idx:
status = 'done'
elif i == state_idx:
status = 'active'
else:
status = 'pending'
ts = job[ts_field] if hasattr(job, ts_field) else None
# Fallback 1: Date -> Datetime for the two ends of the chain.
if not ts and status in ('done', 'active'):
if ts_field == 'received_at' and job.received_date:
ts = datetime.combine(job.received_date, dt_time.min)
elif ts_field == 'shipped_at' and job.actual_ship_date:
ts = datetime.combine(job.actual_ship_date, dt_time.min)
# Fallback 2: linear interpolation for middle stages on records
# that pre-date the per-stage Datetime hook (Task 16). Spreads
# the done stages evenly across received -> now so customers see
# plausible progression instead of a gap-toothed timeline.
if not ts and status == 'done' and baseline and state_idx > 0:
ratio = float(i) / state_idx
ts = baseline + (now - baseline) * ratio
time_label = ''
if ts and status in ('done', 'active'):
# Show full format "May 16, 2026 · 9:14 AM" when we have a
# real time component; date-only "May 16, 2026" when the
# timestamp is at midnight (backfilled or interpolated to
# midnight). Cleaner than showing fake 12:00 AM.
has_time = bool(getattr(ts, 'hour', 0)) or bool(getattr(ts, 'minute', 0)) or bool(getattr(ts, 'second', 0))
if has_time:
time_label = ts.strftime('%b %d, %Y · %-I:%M %p')
else:
time_label = ts.strftime('%b %d, %Y')
elif status == 'pending' and label == 'Shipped' and job.target_ship_date:
time_label = 'est. ' + job.target_ship_date.strftime('%b %d, %Y')
out.append({
'label': label,
'status': status,
'started_at': ts if ts and status in ('done', 'active') else None,
'time_label': time_label,
'notes': '',
})
return out
def _fp_group_documents(self, job):
"""Build the 4-group document panel for the job detail page.
V1: surfaces only the directly-attached fields on fp.portal.job
(coc_attachment_id, packing_list_attachment_id). Other 3 groups
render placeholder/pending rows. V2 (separate change) will wire in
sale_order_id, quote_request_id, part_catalog_id sources.
Returns a list of 4 dicts: {key, label, docs}, where docs is a list
of {label, sub, url, icon_class, icon, pending}.
"""
# 5 fixed groups in display order. Indices used in the appends below
# — if you reorder, update every groups[N] reference.
# 0 = from_you, 1 = specs, 2 = work_order, 3 = quality, 4 = shipping
groups = [
{'key': 'from_you', 'label': 'From You', 'docs': []},
{'key': 'specs', 'label': 'Specifications', 'docs': []},
{'key': 'work_order', 'label': 'Work Order', 'docs': []},
{'key': 'quality', 'label': 'Quality', 'docs': []},
{'key': 'shipping', 'label': 'Shipping', 'docs': []},
]
# 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:
# 1) Customer uploads attached to the SO chatter (PO files,
# drawings, anything they sent over). Surface each as its
# own From-You doc so the customer can re-download what
# they sent us. Filter by mimetype to avoid surfacing
# internal-only files like signatures or logo overrides.
so_atts = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'sale.order'),
('res_id', '=', so.id),
])
for att in so_atts:
groups[0]['docs'].append({
'label': att.name,
'sub': 'Uploaded · %s · %s' % (
att.create_date and att.create_date.strftime('%b %d, %Y') or '',
self._fp_size_label(att),
),
'url': '/web/content/%s?download=true' % att.id,
'icon_class': 'o_fp_doc_icon_input',
'icon': '📄',
})
# 2) Sales Order Confirmation - rendered FP report (not the
# standard sale.report_saleorder; that hits the
# sale_pdf_quote_builder gate per CLAUDE.md MEMORY.md).
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': '/my/jobs/%s/so_confirmation' % job.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 (idx 1) — V1: placeholder (V2 will pull customer spec)
groups[1]['docs'].append({
'label': 'Customer Specification',
'sub': 'Will appear when EN Plating links the spec',
'pending': True,
'icon': '📋',
})
# WORK ORDER (idx 2) — EN Plating WO Detail PDF via
# fusion_plating_jobs.report_fp_job_wo_detail_template. Requires a
# linked backend fp.job; placeholder otherwise.
if backend_job:
groups[2]['docs'].append({
'label': 'Work Order Detail · %s' % job.name,
'sub': 'EN Plating · process scope + recipe summary',
'url': '/my/jobs/%s/wo_detail' % job.id,
'icon_class': 'o_fp_doc_icon_spec',
'icon': '🛠',
})
else:
groups[2]['docs'].append({
'label': 'Work Order Detail',
'sub': 'Will appear once production is confirmed',
'pending': True,
'icon': '🛠',
})
# QUALITY (idx 3) — CoC from coc_attachment_id (the legacy direct field)
if job.coc_attachment_id:
groups[3]['docs'].append({
'label': 'Certificate of Conformance',
'sub': 'EN Plating · %s · %s' % (
job.actual_ship_date and job.actual_ship_date.strftime('%b %d') or '',
self._fp_size_label(job.coc_attachment_id),
),
'url': '/my/jobs/%s/coc' % job.id,
'icon_class': 'o_fp_doc_icon_quality',
'icon': '📑',
})
else:
groups[3]['docs'].append({
'label': 'Certificate of Conformance',
'sub': 'Will appear after QC completes',
'pending': True,
'icon': '📑',
})
# SHIPPING (idx 4) — packing list + tracking
if job.packing_list_attachment_id:
groups[4]['docs'].append({
'label': 'Packing Slip',
'sub': 'EN Plating · %s · %s' % (
job.actual_ship_date and job.actual_ship_date.strftime('%b %d') or '',
self._fp_size_label(job.packing_list_attachment_id),
),
'url': '/web/content/%s?download=true' % job.packing_list_attachment_id.id,
'icon_class': 'o_fp_doc_icon_shipping',
'icon': '📦',
})
else:
groups[4]['docs'].append({
'label': 'Packing Slip · Tracking #',
'sub': 'Available when shipped' + ('' + job.tracking_ref if job.tracking_ref else ''),
'pending': not job.tracking_ref,
'icon': '📦',
})
return groups
def _fp_size_label(self, attachment):
"""Render attachment.file_size as a friendly KB/MB string."""
if not attachment or not attachment.file_size:
return ''
size = attachment.file_size
if size < 1024:
return '%d B' % size
if size < 1024 * 1024:
return '%.0f KB' % (size / 1024)
return '%.1f MB' % (size / (1024 * 1024))
# ==========================================================================
# Account Summary (Sub-A IA) — invoices + credits + statements
# ==========================================================================
_FP_ACCOUNT_SUMMARY_TABS = [
('invoices', 'Invoices', 'out_invoice'),
('credit_memos', 'Credit Memos', 'out_refund'),
('statements', 'Statements', None), # placeholder in V1
]
_FP_ACCOUNT_SUMMARY_FILTERS = ['open', 'closed', 'all']
_FP_ACCOUNT_SUMMARY_SORTS = {
'date_desc': 'invoice_date desc, id desc',
'date_asc': 'invoice_date asc, id asc',
'amount_desc': 'amount_total desc, id desc',
'amount_asc': 'amount_total asc, id asc',
}
_FP_ACCOUNT_SUMMARY_PER_PAGE = 10
def _fp_account_summary_open_balance(self, commercial_partner):
"""Sum of amount_residual across this partner's open invoices.
Uses commercial_partner.env so this helper works both in HTTP
context (where request.env is active) and in unit tests (where
the partner record already carries the test env).
"""
env = commercial_partner.env
moves = env['account.move'].sudo().search([
('partner_id', 'child_of', commercial_partner.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('amount_residual', '>', 0),
])
return sum(moves.mapped('amount_residual'))
def _fp_account_summary_data(self, commercial_partner, tab, filter_state,
search, sort, page):
"""Return {records, total, pager_offset} for one tab+filter combination.
tab — 'invoices' | 'credit_memos' | 'statements'
filter_state — 'open' | 'closed' | 'all'
search — substring matched against name OR ref (case-insensitive)
sort — key from _FP_ACCOUNT_SUMMARY_SORTS
page — 1-indexed
Uses commercial_partner.env so this helper works both in HTTP
context and in unit tests without requiring request to be active.
"""
env = commercial_partner.env
if tab == 'statements':
# V1 placeholder — Statements is a 'coming soon' tab.
return {'records': env['account.move'].browse(), 'total': 0,
'offset': 0}
# Resolve move_type from tab key
move_type = next(
(mt for k, _l, mt in self._FP_ACCOUNT_SUMMARY_TABS if k == tab),
'out_invoice',
)
domain = [
('partner_id', 'child_of', commercial_partner.id),
('move_type', '=', move_type),
('state', '=', 'posted'),
]
if filter_state == 'open':
domain.append(('amount_residual', '>', 0))
elif filter_state == 'closed':
domain.append(('amount_residual', '=', 0))
if search:
domain.append('|')
domain.append(('name', 'ilike', search))
domain.append(('ref', 'ilike', search))
Move = env['account.move'].sudo()
order = self._FP_ACCOUNT_SUMMARY_SORTS.get(sort, 'invoice_date desc')
total = Move.search_count(domain)
offset = max(0, (page - 1) * self._FP_ACCOUNT_SUMMARY_PER_PAGE)
records = Move.search(domain, order=order, limit=self._FP_ACCOUNT_SUMMARY_PER_PAGE, offset=offset)
return {'records': records, 'total': total, 'offset': offset}
@http.route(
['/my/account_summary', '/my/account_summary/page/<int:page>'],
type='http', auth='user', website=True,
)
def portal_account_summary(self, page=1, tab='invoices',
filter_state='open', search='', sort='date_desc',
**kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
# Sanitize inputs
if tab not in [k for k, _l, _t in self._FP_ACCOUNT_SUMMARY_TABS]:
tab = 'invoices'
if filter_state not in self._FP_ACCOUNT_SUMMARY_FILTERS:
filter_state = 'open'
if sort not in self._FP_ACCOUNT_SUMMARY_SORTS:
sort = 'date_desc'
data = self._fp_account_summary_data(
commercial, tab, filter_state, search, sort, page,
)
open_balance = self._fp_account_summary_open_balance(commercial)
pager = portal_pager(
url='/my/account_summary',
url_args={'tab': tab, 'filter_state': filter_state,
'search': search, 'sort': sort},
total=data['total'],
page=page,
step=self._FP_ACCOUNT_SUMMARY_PER_PAGE,
)
currency = (
commercial.property_account_receivable_id.currency_id
if commercial.property_account_receivable_id
else request.env.company.currency_id
)
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'fp_account_summary',
'records': data['records'],
'tabs': self._FP_ACCOUNT_SUMMARY_TABS,
'active_tab': tab,
'filter_state': filter_state,
'search': search,
'sort': sort,
'open_balance': open_balance,
'open_balance_display': formatLang(request.env, open_balance, currency_obj=currency),
'currency': currency,
'pager': pager,
'total': data['total'],
})
return request.render('fusion_plating_portal.portal_my_account_summary', values)
# ==========================================================================
# DASHBOARD
# ==========================================================================
@http.route(
['/my', '/my/home'],
type='http',
auth='user',
website=True,
)
def home(self, **kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
# Recent quotes (5)
Quote = request.env['fusion.plating.quote.request']
quote_domain = [('partner_id', 'child_of', commercial.id)]
recent_quotes = Quote.search(
quote_domain, order='create_date desc', limit=5
)
quote_count = Quote.search_count(quote_domain)
# Recent POs (5) -- sale.order with state=sale
SO = request.env['sale.order'].sudo()
po_domain = [
('partner_id', 'child_of', commercial.id),
('state', '=', 'sale'),
]
recent_pos = SO.search(po_domain, order='date_order desc', limit=5)
po_count = SO.search_count(po_domain)
# Recent jobs (5)
# sudo() so the rendered cards can traverse job.x_fc_job_id -> fp.job
# -> sale_order_id without hitting the portal user's ACL block on
# fp.job. Domain still filters to the customer's own commercial
# partner tree, so sudo doesn't widen visibility.
Job = request.env['fusion.plating.portal.job'].sudo()
job_domain = [('partner_id', 'child_of', commercial.id)]
recent_jobs = Job.search(
job_domain, order='received_date desc, id desc', limit=5
)
job_count = Job.search_count(job_domain)
# Certifications (jobs with CoC)
cert_domain = [
('partner_id', 'child_of', commercial.id),
('coc_attachment_id', '!=', False),
]
recent_certs = Job.search(
cert_domain, order='actual_ship_date desc, id desc', limit=5
)
cert_count = Job.search_count(cert_domain)
# Deliveries (stock.picking outgoing done)
Picking = request.env['stock.picking'].sudo()
delivery_domain = [
('partner_id', 'child_of', commercial.id),
('picking_type_code', '=', 'outgoing'),
('state', '=', 'done'),
]
recent_deliveries = Picking.search(
delivery_domain, order='date_done desc', limit=5
)
delivery_count = Picking.search_count(delivery_domain)
# Invoices
Invoice = request.env['account.move'].sudo()
invoice_domain = [
('partner_id', 'child_of', commercial.id),
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
]
recent_invoices = Invoice.search(
invoice_domain, order='invoice_date desc', limit=5
)
invoice_count = Invoice.search_count(invoice_domain)
# Welcome-line summary counts (jobs-forward dashboard).
active_job_count = Job.search_count([
('partner_id', 'child_of', commercial.id),
('state', 'in', ['received', 'in_progress', 'quality_check']),
])
awaiting_review_count = Quote.search_count([
('partner_id', 'child_of', commercial.id),
('state', '=', 'quoted'),
])
ready_to_ship_count = Job.search_count([
('partner_id', 'child_of', commercial.id),
('state', '=', 'ready_to_ship'),
])
values = self._prepare_portal_layout_values()
values.update({
'page_name': 'fp_dashboard',
'partner': partner,
# Quotes
'recent_quotes': recent_quotes,
'quote_count': quote_count,
# Purchase Orders
'recent_pos': recent_pos,
'po_count': po_count,
# Jobs / Parts Portal
'recent_jobs': recent_jobs,
'job_count': job_count,
# Certifications
'recent_certs': recent_certs,
'cert_count': cert_count,
# Deliveries
'recent_deliveries': recent_deliveries,
'delivery_count': delivery_count,
# Invoices
'recent_invoices': recent_invoices,
'invoice_count': invoice_count,
# Welcome-line summary
'active_job_count': active_job_count,
'awaiting_review_count': awaiting_review_count,
'ready_to_ship_count': ready_to_ship_count,
})
return request.render(
'fusion_plating_portal.fp_portal_home_dashboard',
values,
)
# ==========================================================================
# QUOTE REQUESTS -- list with filter pills + real-time search
# ==========================================================================
@http.route(
['/my/quote_requests'],
type='http',
auth='user',
website=True,
)
def portal_my_quote_requests(self, sortby=None, filter_state=None, **kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
Quote = request.env['fusion.plating.quote.request']
domain = [('partner_id', 'child_of', commercial.id)]
_sort_map = {
'date': 'create_date desc',
'name': 'name asc',
'state': 'state asc',
}
if sortby not in _sort_map:
sortby = 'date'
order = _sort_map[sortby]
# Filter pills
_filter_map = {
'all': [],
'active': [('state', 'in', ['new', 'under_review', 'quoted'])],
'converted': [('state', '=', 'accepted')],
'declined': [('state', 'in', ['declined', 'expired'])],
}
if filter_state not in _filter_map:
filter_state = 'all'
domain += _filter_map[filter_state]
total_count = Quote.search_count(domain)
cap = 500
clipped = total_count > cap
quote_requests = Quote.search(domain, order=order, limit=cap)
request.session['my_fp_quote_requests_history'] = quote_requests.ids[:100]
values = self._prepare_portal_layout_values()
values.update({
'quote_requests': quote_requests,
'page_name': 'fp_quote_requests',
'default_url': '/my/quote_requests',
'sortby': sortby,
'filter_state': filter_state,
'filters': [
('all', 'All'),
('active', 'Active'),
('converted', 'Converted'),
('declined', 'Declined'),
],
'sorts': [
('date', 'Newest'),
('name', 'Reference'),
('state', 'Status'),
],
'result_total': total_count,
'clipped': clipped,
'search': '',
'url': '/my/quote_requests',
'extra_qs': '',
'target': 'tbody.o_fp_qr_filterable',
})
return request.render(
'fusion_plating_portal.portal_my_quote_requests',
values,
)
# ==========================================================================
# QUOTE REQUESTS -- detail
# ==========================================================================
@http.route(
['/my/quote_requests/<int:request_id>'],
type='http',
auth='user',
website=True,
)
def portal_my_quote_request(self, request_id, access_token=None, **kw):
try:
quote_sudo = self._document_check_access(
'fusion.plating.quote.request',
request_id,
access_token,
)
except (AccessError, MissingError):
return request.redirect('/my')
layout_values = self._prepare_portal_layout_values()
values = self._fp_quote_request_get_page_view_values(
quote_sudo, access_token, **kw
)
layout_values.update(values)
return request.render(
'fusion_plating_portal.portal_my_quote_request',
layout_values,
)
# ==========================================================================
# QUOTE REQUESTS -- new form (enhanced)
# ==========================================================================
@http.route(
['/my/quote_requests/new'],
type='http', auth='user', website=True,
methods=['GET'],
)
def portal_new_quote_request(self, **kw):
"""GET — legacy entry point, redirected to the configurator wizard."""
return request.redirect('/my/configurator/new')
# ==========================================================================
# QUOTE REQUESTS -- submit (enhanced for multi-part)
# ==========================================================================
@http.route(
['/my/quote_requests/submit'],
type='http',
auth='user',
website=True,
methods=['POST'],
csrf=True,
)
def portal_submit_quote_request(self, **post):
partner = request.env.user.partner_id
# Basic validation: need at least one part description or a general description
parts_json = post.get('parts_data', '[]')
try:
parts_data = json.loads(parts_json)
except (json.JSONDecodeError, TypeError):
parts_data = []
if not parts_data and not post.get('part_description'):
return request.redirect(
'/my/quote_requests/new?error=missing_description'
)
process_type_ids = []
raw_processes = request.httprequest.form.getlist('process_type_ids')
for pt in raw_processes:
try:
process_type_ids.append(int(pt))
except (TypeError, ValueError):
continue
try:
quantity = int(post.get('quantity') or 0)
except (TypeError, ValueError):
quantity = 0
vals = {
'partner_id': partner.id,
'contact_name': post.get('contact_name') or partner.name,
'contact_email': post.get('contact_email') or partner.email,
'contact_phone': post.get('contact_phone') or partner.phone,
'company_name': post.get('company_name')
or (partner.parent_id.name if partner.parent_id else partner.name),
'part_description': post.get('part_description'),
'quantity': quantity,
'special_instructions': post.get('special_instructions'),
'state': 'new',
'billing_same_as_shipping': bool(post.get('billing_same_as_shipping')),
}
# Addresses
if post.get('shipping_address_id'):
try:
vals['shipping_address_id'] = int(post['shipping_address_id'])
except (TypeError, ValueError):
pass
if post.get('billing_address_id') and not post.get('billing_same_as_shipping'):
try:
vals['billing_address_id'] = int(post['billing_address_id'])
except (TypeError, ValueError):
pass
if post.get('target_delivery'):
vals['target_delivery'] = post.get('target_delivery')
if process_type_ids:
vals['process_type_ids'] = [(6, 0, process_type_ids)]
quote = request.env['fusion.plating.quote.request'].sudo().create(vals)
# Create part lines from JSON data
if parts_data:
Line = request.env['fusion.plating.quote.request.line'].sudo()
for idx, part in enumerate(parts_data):
line_vals = {
'request_id': quote.id,
'sequence': (idx + 1) * 10,
'part_number': part.get('part_number', ''),
'quantity': int(part.get('quantity', 1) or 1),
'count': int(part.get('count', 1) or 1),
'description': part.get('description', ''),
'spec_text': part.get('spec_text', ''),
}
if part.get('product_id'):
try:
line_vals['product_id'] = int(part['product_id'])
except (TypeError, ValueError):
pass
Line.create(line_vals)
# Handle file uploads (general attachments on the quote)
files = request.httprequest.files.getlist('drawing_attachments')
attachment_ids = []
for f in files:
if not f or not f.filename:
continue
try:
content = f.read()
if not content:
continue
attachment = request.env['ir.attachment'].sudo().create({
'name': f.filename,
'datas': base64.b64encode(content),
'res_model': 'fusion.plating.quote.request',
'res_id': quote.id,
'type': 'binary',
})
attachment_ids.append(attachment.id)
except Exception:
continue
if attachment_ids:
quote.sudo().write({
'drawing_attachment_ids': [(6, 0, attachment_ids)],
})
# Handle per-line file uploads (line_file_0, line_file_1, ...)
for idx in range(len(parts_data)):
line_files = request.httprequest.files.getlist('line_file_%d' % idx)
line_att_ids = []
for f in line_files:
if not f or not f.filename:
continue
try:
content = f.read()
if not content:
continue
attachment = request.env['ir.attachment'].sudo().create({
'name': f.filename,
'datas': base64.b64encode(content),
'res_model': 'fusion.plating.quote.request.line',
'res_id': quote.line_ids[idx].id if idx < len(quote.line_ids) else 0,
'type': 'binary',
})
line_att_ids.append(attachment.id)
except Exception:
continue
if line_att_ids and idx < len(quote.line_ids):
quote.line_ids[idx].sudo().write({
'attachment_ids': [(6, 0, line_att_ids)],
})
return request.redirect('/my/quote_requests/%s' % quote.id)
# ==========================================================================
# JOBS -- list with filter pills + real-time search (cards layout)
# ==========================================================================
@http.route(
['/my/jobs'],
type='http',
auth='user',
website=True,
)
def portal_my_jobs(self, sortby=None, filter_state=None, **kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
# sudo() so the rendered cards can traverse job.x_fc_job_id -> fp.job
# -> sale_order_id without hitting the portal user's ACL block on
# fp.job. Domain still filters to the customer's own commercial
# partner tree, so sudo doesn't widen visibility.
Job = request.env['fusion.plating.portal.job'].sudo()
domain = [('partner_id', 'child_of', commercial.id)]
_sort_map = {
'date': 'received_date desc, id desc',
'name': 'name asc',
'state': 'state asc',
}
if sortby not in _sort_map:
sortby = 'date'
order = _sort_map[sortby]
# Filter pills
_filter_map = {
'all': [],
'active': [('state', 'in', ['received', 'in_progress', 'quality_check'])],
'ready': [('state', '=', 'ready_to_ship')],
'complete': [('state', 'in', ['shipped', 'complete'])],
}
if filter_state not in _filter_map:
filter_state = 'all'
domain += _filter_map[filter_state]
total_count = Job.search_count(domain)
cap = 500
clipped = total_count > cap
jobs = Job.search(domain, order=order, limit=cap)
request.session['my_fp_jobs_history'] = jobs.ids[:100]
values = self._prepare_portal_layout_values()
values.update({
'jobs': jobs,
'page_name': 'fp_jobs',
'default_url': '/my/jobs',
'sortby': sortby,
'filter_state': filter_state,
'filters': [
('all', 'All'),
('active', 'Active'),
('ready', 'Ready to Ship'),
('complete', 'Complete'),
],
'sorts': [
('date', 'Newest'),
('name', 'Reference'),
('state', 'Status'),
],
'result_total': total_count,
'clipped': clipped,
'search': '',
'url': '/my/jobs',
'extra_qs': '',
'target': '#fp_jobs_list',
})
return request.render(
'fusion_plating_portal.portal_my_jobs',
values,
)
# ==========================================================================
# JOBS -- detail
# ==========================================================================
@http.route(
['/my/jobs/<int:job_id>'],
type='http',
auth='user',
website=True,
)
def portal_my_job(self, job_id, access_token=None, **kw):
try:
job_sudo = self._document_check_access(
'fusion.plating.portal.job',
job_id,
access_token,
)
except (AccessError, MissingError):
return request.redirect('/my')
layout_values = self._prepare_portal_layout_values()
values = self._fp_portal_job_get_page_view_values(
job_sudo, access_token, **kw
)
values['progress_percent'] = job_sudo._progress_percent()
values['stage_timeline'] = self._fp_get_stage_timeline(job_sudo)
values['doc_groups'] = self._fp_group_documents(job_sudo)
# Spine-fill % for the timeline (visual progress indicator).
# done stages plus half-credit for the active stage.
done_count = sum(1 for s in values['stage_timeline'] if s['status'] == 'done')
active_count = sum(1 for s in values['stage_timeline'] if s['status'] == 'active')
values['timeline_spine_pct'] = int(((done_count + 0.5 * active_count) / 5) * 100)
layout_values.update(values)
return request.render(
'fusion_plating_portal.portal_my_job',
layout_values,
)
# ==========================================================================
# JOBS -- download CoC
# ==========================================================================
@http.route(
['/my/jobs/<int:job_id>/coc'],
type='http',
auth='user',
website=True,
)
def portal_download_coc(self, job_id, access_token=None, **kw):
try:
job_sudo = self._document_check_access(
'fusion.plating.portal.job',
job_id,
access_token,
)
except (AccessError, MissingError):
return request.redirect('/my')
if not job_sudo.coc_attachment_id:
return request.redirect('/my/jobs/%s' % job_id)
attachment = job_sudo.coc_attachment_id.sudo()
return request.env['ir.binary']._get_stream_from(
attachment, 'raw'
).get_response(as_attachment=True)
# ==========================================================================
# JOBS -- download Sales Order Confirmation PDF
# ==========================================================================
# Renders the FP custom sale report with sudo so the QWeb template can
# walk into restricted models (fp.part.catalog etc.) that portal users
# don't have direct ACL on. We still gate on _document_check_access for
# the portal job, so the customer only ever sees their own data.
@http.route(
['/my/jobs/<int:job_id>/so_confirmation'],
type='http',
auth='user',
website=True,
)
def portal_download_so_confirmation(self, job_id, access_token=None, **kw):
try:
job_sudo = self._document_check_access(
'fusion.plating.portal.job',
job_id,
access_token,
)
except (AccessError, MissingError):
return request.redirect('/my')
# Resolve SO via the backend fp.job link.
backend_job = (
job_sudo.x_fc_job_id
if 'x_fc_job_id' in job_sudo._fields
else False
)
so = (
backend_job.sale_order_id
if backend_job and 'sale_order_id' in backend_job._fields
else False
)
if not so:
return request.redirect('/my/jobs/%s' % job_id)
pdf, _content_type = request.env['ir.actions.report'].sudo()._render_qweb_pdf(
'fusion_plating_reports.report_fp_sale_portrait',
res_ids=[so.id],
)
filename = 'Sales-Order-%s.pdf' % so.name.replace('/', '-')
return request.make_response(
pdf,
headers=[
('Content-Type', 'application/pdf'),
('Content-Length', str(len(pdf))),
('Content-Disposition', 'attachment; filename="%s"' % filename),
],
)
# ==========================================================================
# JOBS -- download Work Order Detail PDF
# ==========================================================================
# Renders the fusion_plating_jobs WO Detail report with sudo so the
# template can walk fp.job + recipe nodes the portal user can't read
# directly. Customer-facing summary of process scope and recipe steps.
@http.route(
['/my/jobs/<int:job_id>/wo_detail'],
type='http',
auth='user',
website=True,
)
def portal_download_wo_detail(self, job_id, access_token=None, **kw):
try:
job_sudo = self._document_check_access(
'fusion.plating.portal.job',
job_id,
access_token,
)
except (AccessError, MissingError):
return request.redirect('/my')
backend_job = (
job_sudo.x_fc_job_id
if 'x_fc_job_id' in job_sudo._fields
else False
)
if not backend_job:
return request.redirect('/my/jobs/%s' % job_id)
pdf, _content_type = request.env['ir.actions.report'].sudo()._render_qweb_pdf(
'fusion_plating_jobs.report_fp_job_wo_detail_template',
res_ids=[backend_job.id],
)
filename = 'WO-Detail-%s.pdf' % job_sudo.name.replace('/', '-')
return request.make_response(
pdf,
headers=[
('Content-Type', 'application/pdf'),
('Content-Length', str(len(pdf))),
('Content-Disposition', 'attachment; filename="%s"' % filename),
],
)
# ==========================================================================
# JOBS -- repeat order (clone into a new draft quote_request)
# ==========================================================================
# Customer-initiated "order again". Creates a draft fusion.plating.quote.
# request pre-filled with the user's contact info + job's quantity, then
# redirects to the new RFQ so the customer can adjust + submit. EN
# Plating then prices it via the normal quote workflow.
#
# POST-only so a stray browser prefetch can't accidentally spawn RFQs.
@http.route(
['/my/jobs/<int:job_id>/repeat'],
type='http',
auth='user',
methods=['POST'],
website=True,
csrf=True,
)
def portal_repeat_order(self, job_id, access_token=None, **kw):
try:
job_sudo = self._document_check_access(
'fusion.plating.portal.job',
job_id,
access_token,
)
except (AccessError, MissingError):
return request.redirect('/my')
user = request.env.user
Quote = request.env['fusion.plating.quote.request'].sudo()
new_quote = Quote.create({
'partner_id': user.partner_id.id,
'contact_name': user.name,
'contact_email': user.email or '',
'contact_phone': user.partner_id.phone or '',
'quantity': job_sudo.quantity or 1,
'state': 'new',
# name auto-generated by sequence
})
# Cross-link via chatter so EN Plating's estimator sees the origin.
new_quote.message_post(
body=_('Repeat order requested from portal job %s') % job_sudo.name,
)
return request.redirect('/my/quote_requests/%s' % new_quote.id)
# ==========================================================================
# PURCHASE ORDERS -- list
# ==========================================================================
@http.route(
['/my/purchase_orders', '/my/purchase_orders/page/<int:page>'],
type='http', auth='user', website=True,
)
def portal_my_purchase_orders(self, **kw):
"""Legacy URL — redirected to Odoo default /my/orders (Sub-A IA)."""
return request.redirect('/my/orders')
# ==========================================================================
# INVOICES -- list
# ==========================================================================
@http.route(
['/my/fp_invoices', '/my/fp_invoices/page/<int:page>'],
type='http', auth='user', website=True,
)
def portal_my_fp_invoices(self, **kw):
"""Legacy URL — redirected to /my/account_summary (Sub-A IA)."""
return request.redirect('/my/account_summary')
# ==========================================================================
# SHIPPING / DELIVERIES -- list with search + sort
# ==========================================================================
@http.route(
['/my/deliveries'],
type='http',
auth='user',
website=True,
)
def portal_my_deliveries(self, sortby=None, **kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
Picking = request.env['stock.picking'].sudo()
domain = [
('partner_id', 'child_of', commercial.id),
('picking_type_code', '=', 'outgoing'),
('state', '=', 'done'),
]
_sort_map = {
'date': 'date_done desc',
'name': 'name asc',
}
if sortby not in _sort_map:
sortby = 'date'
order = _sort_map[sortby]
total_count = Picking.search_count(domain)
cap = 500
clipped = total_count > cap
deliveries = Picking.search(domain, order=order, limit=cap)
values = self._prepare_portal_layout_values()
values.update({
'deliveries': deliveries,
'page_name': 'fp_deliveries',
'default_url': '/my/deliveries',
'sortby': sortby,
'filter_state': 'all',
'filters': False,
'sorts': [
('date', 'Newest'),
('name', 'Reference'),
],
'result_total': total_count,
'clipped': clipped,
'search': '',
'url': '/my/deliveries',
'extra_qs': '',
'target': 'tbody.o_fp_deliveries_filterable',
})
return request.render(
'fusion_plating_portal.portal_my_deliveries',
values,
)
# ==========================================================================
# CERTIFICATIONS -- list with search + sort
# ==========================================================================
@http.route(
['/my/certifications'],
type='http',
auth='user',
website=True,
)
def portal_my_certifications(self, sortby=None, **kw):
partner = request.env.user.partner_id
commercial = partner.commercial_partner_id
# sudo() so the rendered cards can traverse job.x_fc_job_id -> fp.job
# -> sale_order_id without hitting the portal user's ACL block on
# fp.job. Domain still filters to the customer's own commercial
# partner tree, so sudo doesn't widen visibility.
Job = request.env['fusion.plating.portal.job'].sudo()
domain = [
('partner_id', 'child_of', commercial.id),
('coc_attachment_id', '!=', False),
]
_sort_map = {
'date': 'actual_ship_date desc, id desc',
'name': 'name asc',
}
if sortby not in _sort_map:
sortby = 'date'
order = _sort_map[sortby]
total_count = Job.search_count(domain)
cap = 500
clipped = total_count > cap
cert_jobs = Job.search(domain, order=order, limit=cap)
values = self._prepare_portal_layout_values()
values.update({
'cert_jobs': cert_jobs,
'page_name': 'fp_certifications',
'default_url': '/my/certifications',
'sortby': sortby,
'filter_state': 'all',
'filters': False,
'sorts': [
('date', 'Newest'),
('name', 'Reference'),
],
'result_total': total_count,
'clipped': clipped,
'search': '',
'url': '/my/certifications',
'extra_qs': '',
'target': 'tbody.o_fp_certs_filterable',
})
return request.render(
'fusion_plating_portal.portal_my_certifications',
values,
)
# ==========================================================================
# JSONRPC -- product search for RFQ form
# ==========================================================================
@http.route(
['/my/quote_requests/search_products'],
type='jsonrpc',
auth='user',
website=True,
)
def portal_search_products(self, query='', **kw):
"""Search products for the RFQ part number dropdown."""
products = request.env['product.product'].sudo().search([
('sale_ok', '=', True),
('active', '=', True),
'|', '|',
('name', 'ilike', query),
('default_code', 'ilike', query),
('barcode', 'ilike', query),
], limit=20, order='default_code, name')
return [{
'id': p.id,
'name': p.name,
'default_code': p.default_code or '',
'display': '[%s] %s' % (p.default_code, p.name) if p.default_code else p.name,
} for p in products]