folder rename
This commit is contained in:
9
fusion_plating/fusion_plating_portal/models/__init__.py
Normal file
9
fusion_plating/fusion_plating_portal/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import fp_quote_request
|
||||
from . import fp_quote_request_line
|
||||
from . import fp_portal_job
|
||||
from . import res_partner
|
||||
127
fusion_plating/fusion_plating_portal/models/fp_portal_job.py
Normal file
127
fusion_plating/fusion_plating_portal/models/fp_portal_job.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class FpPortalJob(models.Model):
|
||||
"""Lightweight portal-facing view of a production job.
|
||||
|
||||
This is intentionally a simple, decoupled model — it does NOT replace any
|
||||
real job/MO model from process packs (e.g. fusion_plating_process_en).
|
||||
Instead, the shop populates this once per job (manually or via a small
|
||||
sync rule from the real job) so the customer sees a clean, sanitised
|
||||
summary on the portal without exposing internal records.
|
||||
|
||||
Each portal job carries the headline state, target/actual ship dates,
|
||||
optional CoC + packing list attachments, and a tracking reference.
|
||||
"""
|
||||
_name = 'fusion.plating.portal.job'
|
||||
_description = 'Fusion Plating — Portal Job'
|
||||
_inherit = ['portal.mixin', 'mail.thread']
|
||||
_order = 'received_date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Job Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
tracking=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Customer',
|
||||
required=True,
|
||||
index=True,
|
||||
tracking=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
('received', 'Received'),
|
||||
('in_progress', 'In Progress'),
|
||||
('quality_check', 'Quality Check'),
|
||||
('ready_to_ship', 'Ready to Ship'),
|
||||
('shipped', 'Shipped'),
|
||||
('complete', 'Complete'),
|
||||
],
|
||||
string='Status',
|
||||
default='received',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
received_date = fields.Date(
|
||||
string='Received Date',
|
||||
default=fields.Date.context_today,
|
||||
tracking=True,
|
||||
)
|
||||
target_ship_date = fields.Date(
|
||||
string='Target Ship Date',
|
||||
tracking=True,
|
||||
)
|
||||
actual_ship_date = fields.Date(
|
||||
string='Actual Ship Date',
|
||||
tracking=True,
|
||||
)
|
||||
process_type_ids = fields.Many2many(
|
||||
'fusion.plating.process.type',
|
||||
'fp_portal_job_process_type_rel',
|
||||
'job_id',
|
||||
'process_type_id',
|
||||
string='Processes',
|
||||
)
|
||||
quantity = fields.Integer(
|
||||
string='Quantity',
|
||||
default=1,
|
||||
)
|
||||
tracking_ref = fields.Char(
|
||||
string='Tracking Reference',
|
||||
)
|
||||
coc_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Certificate of Conformance',
|
||||
ondelete='set null',
|
||||
)
|
||||
packing_list_attachment_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Packing List',
|
||||
ondelete='set null',
|
||||
)
|
||||
invoice_ref = fields.Char(
|
||||
string='Invoice Reference',
|
||||
)
|
||||
notes = fields.Html(
|
||||
string='Customer-Visible Notes',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Portal access
|
||||
# ==========================================================================
|
||||
def _compute_access_url(self):
|
||||
super()._compute_access_url()
|
||||
for rec in self:
|
||||
rec.access_url = '/my/jobs/%s' % rec.id
|
||||
|
||||
# ==========================================================================
|
||||
# Helpers
|
||||
# ==========================================================================
|
||||
@api.model
|
||||
def _state_progress_map(self):
|
||||
"""Return a dict mapping state -> progress percent for the portal bar."""
|
||||
return {
|
||||
'received': 10,
|
||||
'in_progress': 35,
|
||||
'quality_check': 60,
|
||||
'ready_to_ship': 80,
|
||||
'shipped': 95,
|
||||
'complete': 100,
|
||||
}
|
||||
|
||||
def _progress_percent(self):
|
||||
self.ensure_one()
|
||||
return self._state_progress_map().get(self.state, 0)
|
||||
265
fusion_plating/fusion_plating_portal/models/fp_quote_request.py
Normal file
265
fusion_plating/fusion_plating_portal/models/fp_quote_request.py
Normal file
@@ -0,0 +1,265 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class FpQuoteRequest(models.Model):
|
||||
"""Customer-submitted Request for Quote (RFQ).
|
||||
|
||||
The RFQ is the entry point for new business through the customer portal.
|
||||
A customer fills out the public form (logged in), uploads any drawings,
|
||||
and submits — the record lands in the shop's backend in state ``new``.
|
||||
|
||||
The shop reviews, prices, and either quotes (``quoted``), declines, or
|
||||
lets the request expire. The portal mixin gives each request a stable
|
||||
access token URL so quote PDFs can be linked from chatter.
|
||||
"""
|
||||
_name = 'fusion.plating.quote.request'
|
||||
_description = 'Fusion Plating — Quote Request'
|
||||
_inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin']
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: _('New'),
|
||||
tracking=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Customer',
|
||||
required=True,
|
||||
index=True,
|
||||
tracking=True,
|
||||
)
|
||||
contact_name = fields.Char(
|
||||
string='Contact Name',
|
||||
tracking=True,
|
||||
)
|
||||
contact_email = fields.Char(
|
||||
string='Contact Email',
|
||||
tracking=True,
|
||||
)
|
||||
contact_phone = fields.Char(
|
||||
string='Contact Phone',
|
||||
)
|
||||
company_name = fields.Char(
|
||||
string='Company',
|
||||
)
|
||||
part_description = fields.Html(
|
||||
string='Part Description',
|
||||
)
|
||||
process_type_ids = fields.Many2many(
|
||||
'fusion.plating.process.type',
|
||||
'fp_quote_request_process_type_rel',
|
||||
'request_id',
|
||||
'process_type_id',
|
||||
string='Requested Processes',
|
||||
)
|
||||
quantity = fields.Integer(
|
||||
string='Quantity',
|
||||
default=1,
|
||||
)
|
||||
target_delivery = fields.Date(
|
||||
string='Target Delivery',
|
||||
)
|
||||
special_instructions = fields.Html(
|
||||
string='Special Instructions',
|
||||
)
|
||||
drawing_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fp_quote_request_attachment_rel',
|
||||
'request_id',
|
||||
'attachment_id',
|
||||
string='Drawings & Attachments',
|
||||
)
|
||||
|
||||
state = fields.Selection(
|
||||
[
|
||||
('new', 'New'),
|
||||
('under_review', 'Under Review'),
|
||||
('quoted', 'Quoted'),
|
||||
('accepted', 'Accepted'),
|
||||
('declined', 'Declined'),
|
||||
('expired', 'Expired'),
|
||||
],
|
||||
string='Status',
|
||||
default='new',
|
||||
tracking=True,
|
||||
required=True,
|
||||
)
|
||||
|
||||
quoted_price = fields.Monetary(
|
||||
string='Quoted Price',
|
||||
currency_field='currency_id',
|
||||
tracking=True,
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
quoted_by_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Quoted By',
|
||||
tracking=True,
|
||||
)
|
||||
quote_sent_date = fields.Datetime(
|
||||
string='Quote Sent',
|
||||
tracking=True,
|
||||
)
|
||||
customer_response_date = fields.Datetime(
|
||||
string='Customer Responded',
|
||||
tracking=True,
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fusion.plating.quote.request.line',
|
||||
'request_id',
|
||||
string='Part Lines',
|
||||
)
|
||||
shipping_address_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Shipping Address',
|
||||
)
|
||||
billing_address_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Billing Address',
|
||||
)
|
||||
billing_same_as_shipping = fields.Boolean(
|
||||
string='Billing Same as Shipping',
|
||||
default=True,
|
||||
)
|
||||
notes_internal = fields.Html(
|
||||
string='Internal Notes',
|
||||
help='Visible to shop users only — never shown on the customer portal.',
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# ORM
|
||||
# ==========================================================================
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name') or vals.get('name') == _('New'):
|
||||
seq = self.env['ir.sequence'].next_by_code(
|
||||
'fusion.plating.quote.request'
|
||||
)
|
||||
vals['name'] = seq or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
# ==========================================================================
|
||||
# Portal access
|
||||
# ==========================================================================
|
||||
def _compute_access_url(self):
|
||||
super()._compute_access_url()
|
||||
for rec in self:
|
||||
rec.access_url = '/my/quote_requests/%s' % rec.id
|
||||
|
||||
# ==========================================================================
|
||||
# Actions
|
||||
# ==========================================================================
|
||||
def action_mark_under_review(self):
|
||||
self.write({'state': 'under_review'})
|
||||
|
||||
def action_send_quote(self):
|
||||
self.write({
|
||||
'state': 'quoted',
|
||||
'quote_sent_date': fields.Datetime.now(),
|
||||
'quoted_by_id': self.env.user.id,
|
||||
})
|
||||
|
||||
def action_mark_accepted(self):
|
||||
self.write({
|
||||
'state': 'accepted',
|
||||
'customer_response_date': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GAP 1: Quote → Sale Order
|
||||
# ------------------------------------------------------------------
|
||||
def action_create_sale_order(self):
|
||||
"""Create a sale order from this accepted quote request.
|
||||
|
||||
Populates SO lines from the quote request lines (if any) or
|
||||
from the legacy single-part fields. Returns the SO action so
|
||||
the user lands on the new order.
|
||||
"""
|
||||
self.ensure_one()
|
||||
SaleOrder = self.env['sale.order']
|
||||
SaleOrderLine = self.env['sale.order.line']
|
||||
|
||||
so_vals = {
|
||||
'partner_id': self.partner_id.id,
|
||||
'origin': self.name,
|
||||
'company_id': self.company_id.id,
|
||||
'note': self.special_instructions or '',
|
||||
}
|
||||
if self.shipping_address_id:
|
||||
so_vals['partner_shipping_id'] = self.shipping_address_id.id
|
||||
if self.billing_address_id:
|
||||
so_vals['partner_invoice_id'] = self.billing_address_id.id
|
||||
|
||||
so = SaleOrder.create(so_vals)
|
||||
|
||||
# Create SO lines from quote lines
|
||||
if self.line_ids:
|
||||
for line in self.line_ids:
|
||||
product = line.product_id
|
||||
if not product:
|
||||
continue
|
||||
SaleOrderLine.create({
|
||||
'order_id': so.id,
|
||||
'product_id': product.id,
|
||||
'product_uom_qty': line.quantity or 1,
|
||||
'name': line.description or product.display_name,
|
||||
'price_unit': self.quoted_price / max(len(self.line_ids), 1) if self.quoted_price else product.list_price,
|
||||
})
|
||||
elif self.quantity and self.quoted_price:
|
||||
# Fallback: create a generic service line from the old single-part fields
|
||||
generic_product = self.env.ref(
|
||||
'fusion_plating_portal.product_plating_service',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
SaleOrderLine.create({
|
||||
'order_id': so.id,
|
||||
'product_id': generic_product.id if generic_product else False,
|
||||
'product_uom_qty': self.quantity,
|
||||
'name': self.part_description or 'Plating Service',
|
||||
'price_unit': self.quoted_price,
|
||||
})
|
||||
|
||||
# Link back
|
||||
self.write({'state': 'accepted'})
|
||||
self.message_post(body=_(
|
||||
'Sale Order <a href="/odoo/sales/%(so_id)s">%(so_name)s</a> created.',
|
||||
so_id=so.id,
|
||||
so_name=so.name,
|
||||
))
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': so.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_mark_declined(self):
|
||||
self.write({
|
||||
'state': 'declined',
|
||||
'customer_response_date': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
def action_mark_expired(self):
|
||||
self.write({'state': 'expired'})
|
||||
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpQuoteRequestLine(models.Model):
|
||||
"""Individual part line on a customer-submitted RFQ.
|
||||
|
||||
A quote request can contain multiple parts, each with its own
|
||||
part number, quantity, description, and file attachments.
|
||||
"""
|
||||
_name = 'fusion.plating.quote.request.line'
|
||||
_description = 'Fusion Plating — Quote Request Line'
|
||||
_order = 'sequence, id'
|
||||
|
||||
request_id = fields.Many2one(
|
||||
'fusion.plating.quote.request',
|
||||
string='Quote Request',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
)
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
'product.product',
|
||||
string='Part',
|
||||
)
|
||||
part_number = fields.Char(
|
||||
string='Part Number',
|
||||
)
|
||||
quantity = fields.Integer(
|
||||
string='Quantity',
|
||||
default=1,
|
||||
)
|
||||
count = fields.Integer(
|
||||
string='Count',
|
||||
default=1,
|
||||
help='Number of pieces per quantity unit.',
|
||||
)
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
)
|
||||
spec_text = fields.Text(
|
||||
string='Spec Parameters',
|
||||
help='Customer specification parameters for this part.',
|
||||
)
|
||||
attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'fp_quote_request_line_attachment_rel',
|
||||
'line_id',
|
||||
'attachment_id',
|
||||
string='Files',
|
||||
)
|
||||
45
fusion_plating/fusion_plating_portal/models/res_partner.py
Normal file
45
fusion_plating/fusion_plating_portal/models/res_partner.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
x_fc_portal_enabled = fields.Boolean(
|
||||
string='Plating Portal Access',
|
||||
default=False,
|
||||
help='Allow this customer to see Plating quote requests and jobs '
|
||||
'in their portal.',
|
||||
)
|
||||
x_fc_quote_request_ids = fields.One2many(
|
||||
'fusion.plating.quote.request',
|
||||
'partner_id',
|
||||
string='Quote Requests',
|
||||
)
|
||||
x_fc_portal_job_ids = fields.One2many(
|
||||
'fusion.plating.portal.job',
|
||||
'partner_id',
|
||||
string='Plating Jobs',
|
||||
)
|
||||
x_fc_quote_request_count = fields.Integer(
|
||||
string='Quote Request Count',
|
||||
compute='_compute_x_fc_quote_request_count',
|
||||
)
|
||||
x_fc_portal_job_count = fields.Integer(
|
||||
string='Plating Job Count',
|
||||
compute='_compute_x_fc_portal_job_count',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_quote_request_ids')
|
||||
def _compute_x_fc_quote_request_count(self):
|
||||
for partner in self:
|
||||
partner.x_fc_quote_request_count = len(partner.x_fc_quote_request_ids)
|
||||
|
||||
@api.depends('x_fc_portal_job_ids')
|
||||
def _compute_x_fc_portal_job_count(self):
|
||||
for partner in self:
|
||||
partner.x_fc_portal_job_count = len(partner.x_fc_portal_job_ids)
|
||||
Reference in New Issue
Block a user