266 lines
8.4 KiB
Python
266 lines
8.4 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.
|
|
|
|
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'})
|