New module commercial_invoice for preparing and printing commercial invoices.

This commit is contained in:
Marc Durepos 2025-01-20 14:12:32 -05:00
parent 30afde78cc
commit 0644586a52
17 changed files with 638 additions and 0 deletions

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import report
from . import models

View file

@ -0,0 +1,32 @@
{
'name': 'Commercial Invoice',
'version': '1.0',
'category': 'Accounting',
'summary': 'Generate commercial invoices for cross-border shipments',
'description': """
Generate commercial invoices for cross-border shipments between Canada and the USA.
Features:
- Group multiple invoices into a single commercial invoice
- Track additional costs (packaging, freight, insurance)
- Print bilingual commercial invoice reports
""",
'author': 'marc@bemade.org',
'website': 'https://www.bemade.org',
'license': 'LGPL-3',
'depends': [
'account',
'stock_delivery',
'mail',
],
'data': [
'security/ir.model.access.csv',
'data/commercial_invoice_sequence.xml',
'report/commercial_invoice_report.xml',
'report/report_templates.xml',
'views/account_move_views.xml',
'views/commercial_invoice_views.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_commercial_invoice" model="ir.sequence">
<field name="name">Commercial Invoice</field>
<field name="code">commercial.invoice</field>
<field name="prefix">CI/%(year)s/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,2 @@
from . import commercial_invoice
from . import account_move

View file

@ -0,0 +1,15 @@
from odoo import models
class AccountMove(models.Model):
_inherit = 'account.move'
def action_create_commercial_invoice(self):
"""Create a commercial invoice from selected invoices."""
commercial_invoice = self.env['commercial.invoice'].create_from_invoices(self)
return {
'type': 'ir.actions.act_window',
'res_model': 'commercial.invoice',
'res_id': commercial_invoice.id,
'view_mode': 'form',
'target': 'current',
}

View file

@ -0,0 +1,114 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class CommercialInvoice(models.Model):
_name = 'commercial.invoice'
_description = 'Commercial Invoice for Export'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'date desc, id desc'
name = fields.Char(string='Reference', required=True, copy=False, readonly=True, default='New')
date = fields.Date(string='Export Date', required=True, default=fields.Date.context_today)
state = fields.Selection([
('draft', 'Draft'),
('done', 'Done'),
('cancelled', 'Cancelled')
], string='Status', default='draft', tracking=True)
# Related parties
partner_id = fields.Many2one('res.partner', string='Consignee', required=True)
importer_id = fields.Many2one('res.partner', string='Importer of Record')
customs_broker_id = fields.Many2one('res.partner', string='Customs Broker')
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
currency_id = fields.Many2one('res.currency', string='Currency', required=True,
default=lambda self: self.env.company.currency_id)
related_parties = fields.Boolean(string='Related Parties', default=False)
# Invoice lines and related fields
invoice_ids = fields.Many2many('account.move', string='Invoices', domain=[('move_type', '=', 'out_invoice')])
payment_term_id = fields.Many2one('account.payment.term', string='Payment Terms')
incoterm_id = fields.Many2one('account.incoterms', string='Incoterms')
# Shipping details
number_of_packages = fields.Integer(string='Number of Packages')
total_weight = fields.Float(string='Total Weight (kg)')
packaging_cost = fields.Monetary(string='Packaging Cost', currency_field='currency_id')
freight_cost = fields.Monetary(string='Freight Cost', currency_field='currency_id')
insurance_cost = fields.Monetary(string='Insurance Cost', currency_field='currency_id')
other_cost = fields.Monetary(string='Other Costs', currency_field='currency_id')
# Computed fields
invoice_amount = fields.Monetary(string='Invoice Amount', currency_field='currency_id',
compute='_compute_amounts', store=True)
total_amount = fields.Monetary(string='Total Amount', currency_field='currency_id',
compute='_compute_amounts', store=True)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code('commercial.invoice') or 'New'
return super().create(vals_list)
@api.depends('invoice_ids', 'packaging_cost', 'freight_cost', 'insurance_cost', 'other_cost')
def _compute_amounts(self):
for record in self:
record.invoice_amount = sum(record.invoice_ids.mapped('amount_total'))
record.total_amount = (record.invoice_amount + record.packaging_cost +
record.freight_cost + record.insurance_cost + record.other_cost)
def action_confirm(self):
self.write({'state': 'done'})
def action_draft(self):
self.write({'state': 'draft'})
def action_cancel(self):
self.write({'state': 'cancelled'})
@api.onchange('partner_id')
def _onchange_partner_id(self):
if self.partner_id:
self.payment_term_id = self.partner_id.property_payment_term_id
@api.model
def _prepare_commercial_invoice_from_invoices(self, invoices):
"""Prepare commercial invoice values from a set of invoices."""
if not invoices:
raise UserError(_("No invoices selected."))
# Get unique values for key fields
currencies = invoices.mapped('currency_id')
payment_terms = invoices.mapped('invoice_payment_term_id')
incoterms = invoices.mapped('invoice_incoterm_id')
companies = invoices.mapped('company_id')
# Validate consistency
if len(currencies) > 1:
raise UserError(_("Selected invoices have different currencies."))
if len(companies) > 1:
raise UserError(_("Selected invoices are from different companies."))
# Get shipping and billing partners
shipping_partners = invoices.mapped('partner_shipping_id.commercial_partner_id')
billing_partners = invoices.mapped('partner_id.commercial_partner_id')
# Prepare values
vals = {
'invoice_ids': [(6, 0, invoices.ids)],
'company_id': companies[0].id,
'currency_id': currencies[0].id,
'partner_id': shipping_partners[0].id if len(shipping_partners) == 1 else False,
'importer_id': billing_partners[0].id if len(billing_partners) == 1 else False,
'payment_term_id': payment_terms[0].id if len(payment_terms) == 1 else False,
'incoterm_id': incoterms[0].id if len(incoterms) == 1 else False,
}
return vals
@api.model
def create_from_invoices(self, invoices):
"""Create a commercial invoice from a set of invoices."""
vals = self._prepare_commercial_invoice_from_invoices(invoices)
return self.create(vals)

View file

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_report_commercial_invoice" model="ir.actions.report">
<field name="name">Commercial Invoice</field>
<field name="model">commercial.invoice</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">commercial_invoice.report_commercial_invoice</field>
<field name="report_file">commercial_invoice.report_commercial_invoice</field>
<field name="print_report_name">'Commercial Invoice - %s' % object.name</field>
<field name="binding_model_id" ref="model_commercial_invoice"/>
<field name="binding_type">report</field>
</record>
</odoo>

View file

@ -0,0 +1,219 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Main template that will be called by the action -->
<template id="report_commercial_invoice">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="commercial_invoice.report_commercial_invoice_document"/>
</t>
</t>
</template>
<!-- Document template with the actual content -->
<template id="report_commercial_invoice_document">
<t t-call="web.external_layout">
<div class="page">
<h2 class="text-center">Commercial Invoice / Facture Commerciale</h2>
<!-- Header Info -->
<table class="table table-sm table-bordered mt-4">
<tr>
<td width="33%">
<strong>Export Date / Date d'exportation:</strong><br/>
<span t-field="o.date"/>
</td>
<td width="33%">
<strong>Invoice # / No. de facture:</strong><br/>
<span t-field="o.name"/>
</td>
<td width="33%">
<strong>Related Parties / Parties liées:</strong><br/>
<span t-out="'Yes / Oui' if o.related_parties else 'No / Non'"></span>
</td>
</tr>
</table>
<!-- Shipper/Consignee -->
<table class="table table-sm table-bordered">
<tr>
<td width="50%">
<strong>Shipper/Exporter - Expéditeur/Exportateur:</strong><br/>
<div t-field="o.company_id.partner_id" t-options='{"widget": "contact", "fields": ["name", "address"], "no_marker": True}'/>
<div t-if="o.company_id.vat">Tax ID: <span t-field="o.company_id.vat"/></div>
</td>
<td width="50%">
<strong>Consignee - Destinataire:</strong><br/>
<div t-field="o.partner_id" t-options='{"widget": "contact", "fields": ["name", "address"], "no_marker": True}'/>
<div t-if="o.partner_id.vat">Tax ID: <span t-field="o.partner_id.vat"/></div>
</td>
</tr>
</table>
<!-- Export/Terms/Currency -->
<table class="table table-sm table-bordered">
<tr>
<td width="33%">
<strong>Country of Export / Pays d'exportation:</strong><br/>
<span t-field="o.company_id.country_id.name"/>
</td>
<td width="33%">
<strong>Terms of Sale / Conditions de vente:</strong><br/>
<span t-field="o.payment_term_id.name"/>
</td>
<td width="33%">
<strong>Currency / Devise:</strong><br/>
<span t-field="o.currency_id.name"/>
</td>
</tr>
</table>
<!-- Origin/Importer -->
<table class="table table-sm table-bordered">
<tr>
<td width="50%">
<strong>Country of Origin / Pays d'origine:</strong><br/>
<t t-set="all_lines" t-value="o.invoice_ids.mapped('invoice_line_ids')"/>
<t t-set="origins" t-value="all_lines.mapped('product_id.country_of_origin.name')"/>
<span t-out="', '.join([x for x in origins if x])"/>
</td>
<td width="50%" rowspan="2">
<strong>Importer of Record / Importateur attitré:</strong><br/>
<span>(if different than consignee / si différent du destinataire)</span><br/>
<t t-if="o.importer_id != o.partner_id">
<div t-field="o.importer_id" t-options='{"widget": "contact", "fields": ["name", "address"], "no_marker": True}'/>
</t>
</td>
</tr>
<tr>
<td>
<strong>Country of Ultimate Destination / Pays de destination finale:</strong><br/>
<span t-field="o.partner_id.country_id.name"/>
</td>
</tr>
</table>
<!-- Line Items -->
<table class="table table-sm table-bordered mt-4">
<thead>
<tr>
<th>Item # &amp; Description<br/>No. d'article &amp; description</th>
<th>Client Item #<br/>No. d'article client</th>
<th>HS #<br/>No. SH</th>
<th>Quantity<br/>Quantité</th>
<th>Unit Value<br/>Valeur unitaire</th>
<th>Total Value<br/>Valeur totale</th>
</tr>
</thead>
<tbody>
<t t-set="all_lines" t-value="o.invoice_ids.mapped('invoice_line_ids')"/>
<tr t-foreach="all_lines" t-as="line">
<td>
<span t-field="line.product_id.name"/>
<t t-if="line.name != line.product_id.name">
<br/><span class="text-muted" t-field="line.name"/>
</t>
</td>
<td><span t-field="line.product_id.default_code"/></td>
<td><span t-field="line.product_id.hs_code"/></td>
<td>
<span t-field="line.quantity"/>
<span t-field="line.product_uom_id" groups="uom.group_uom"/>
</td>
<td>
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
</td>
<td>
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
</td>
</tr>
</tbody>
</table>
<!-- Costs and Totals -->
<div class="row">
<div class="col-7">
<!-- Declaration -->
<p class="small">
These commodities, technology or software were exported from Canada in accordance with the Export
Administration Regulations. Diversion contrary to Canadian law prohibited.<br/>
It is hereby certified that this invoice shows the actual price of the goods described, that no other invoice has
been issued and that all particulars are true and correct.<br/>
----------------------------------------------------------------------------<br/>
Ces marchandises, technologies ou logiciels ont été exportés du Canada conformément aux règlements
administratifs sur l'exportation des Etats-Unis. Tout agissement contraire à la loi Canadienne est strictement
interdit.<br/>
Je certifie par la présente que les prix indiqués sur cette facture sont exacts, qu'aucune autre facture
commerciale n'a été produite et que tous les renseignements fournis sont véridiques.
</p>
<!-- Signature -->
<div class="mt-4">
<div>Signature: _______________________</div>
<div>Title / Titre: _______________________</div>
<div>Date: _______________________</div>
</div>
</div>
<div class="col-5">
<table class="table table-sm">
<tr>
<td><strong>Packaging / Emballage:</strong></td>
<td class="text-right">
<span t-field="o.packaging_cost" t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
</td>
</tr>
<tr>
<td><strong>Freight / Fret:</strong></td>
<td class="text-right">
<span t-field="o.freight_cost" t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
</td>
</tr>
<tr>
<td><strong>Insurance / Assurance:</strong></td>
<td class="text-right">
<span t-field="o.insurance_cost" t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
</td>
</tr>
<tr>
<td><strong>Other / Autre:</strong></td>
<td class="text-right">
<span t-field="o.other_cost" t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
</td>
</tr>
<tr class="border-black">
<td><strong>Total Invoice Value / Valeur totale de la facture:</strong></td>
<td class="text-right">
<strong t-field="o.total_amount" t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
</td>
</tr>
</table>
</div>
</div>
<!-- Footer Info -->
<table class="table table-sm table-bordered mt-4">
<tr>
<td width="33%">
<strong>Customs Broker / Courtier en douane:</strong><br/>
<span t-field="o.customs_broker_id.name"/>
</td>
<td width="33%">
<strong>Number of Packages / Nombre de colis:</strong><br/>
<span t-field="o.number_of_packages"/>
</td>
<td width="33%">
<strong>Total Weight / Poids total:</strong><br/>
<span t-field="o.total_weight"/> kg
</td>
</tr>
<tr>
<td colspan="3">
<strong>Incoterms:</strong>
<t t-if="o.incoterm_id">
<span t-field="o.incoterm_id.code"/> - <span t-field="o.incoterm_id.name"/>
</t>
</td>
</tr>
</table>
</div>
</t>
</template>
</odoo>

View file

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_commercial_invoice_user,commercial.invoice.user,model_commercial_invoice,account.group_account_invoice,1,1,1,1
access_commercial_invoice_manager,commercial.invoice.manager,model_commercial_invoice,account.group_account_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_commercial_invoice_user commercial.invoice.user model_commercial_invoice account.group_account_invoice 1 1 1 1
3 access_commercial_invoice_manager commercial.invoice.manager model_commercial_invoice account.group_account_manager 1 1 1 1

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_move_list_inherit_commercial_invoice" model="ir.ui.view">
<field name="name">account.move.list.commercial.invoice</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_out_invoice_tree"/>
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<header>
<button name="action_create_commercial_invoice"
type="object"
string="Create Commercial Invoice"/>
</header>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="commercial_invoice_view_tree" model="ir.ui.view">
<field name="name">commercial.invoice.tree</field>
<field name="model">commercial.invoice</field>
<field name="arch" type="xml">
<list string="Commercial Invoices" decoration-info="state == 'draft'" decoration-muted="state == 'cancelled'">
<field name="name"/>
<field name="date"/>
<field name="partner_id"/>
<field name="invoice_amount"/>
<field name="total_amount"/>
<field name="currency_id" invisible="1"/>
<field name="state"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="commercial_invoice_view_form" model="ir.ui.view">
<field name="name">commercial.invoice.form</field>
<field name="model">commercial.invoice</field>
<field name="arch" type="xml">
<form string="Commercial Invoice">
<header>
<button name="action_confirm" string="Confirm" type="object" class="oe_highlight"
invisible="state != 'draft'"/>
<button name="action_draft" string="Reset to Draft" type="object"
invisible="state == 'draft'"/>
<button name="action_cancel" string="Cancel" type="object"
invisible="state == 'cancelled'"/>
<button name="%(action_report_commercial_invoice)d" string="Print" type="action" class="oe_highlight"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<group>
<group>
<field name="partner_id" readonly="state != 'draft'"/>
<field name="importer_id" readonly="state != 'draft'"/>
<field name="customs_broker_id" readonly="state != 'draft'"/>
<field name="payment_term_id" readonly="state != 'draft'"/>
<field name="incoterm_id" readonly="state != 'draft'"/>
</group>
<group>
<field name="date" readonly="state != 'draft'"/>
<field name="company_id" groups="base.group_multi_company" readonly="state != 'draft'"/>
<field name="currency_id" groups="base.group_multi_currency" readonly="state != 'draft'"/>
<field name="number_of_packages" readonly="state != 'draft'"/>
<field name="total_weight" readonly="state != 'draft'"/>
</group>
</group>
<notebook>
<page string="Invoices" name="invoices">
<field name="invoice_ids" readonly="state != 'draft'">
<list>
<field name="name" readonly="1"/>
<field name="partner_id" readonly="1"/>
<field name="invoice_date" readonly="1"/>
<field name="amount_total" readonly="1"/>
</list>
</field>
<group class="oe_subtotal_footer">
<field name="invoice_amount" class="oe_subtotal_footer_separator"/>
<field name="packaging_cost" widget="monetary"/>
<field name="freight_cost" widget="monetary"/>
<field name="insurance_cost" widget="monetary"/>
<field name="other_cost" widget="monetary"/>
<field name="total_amount" class="oe_subtotal_footer_separator" widget="monetary"/>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Search View -->
<record id="commercial_invoice_view_search" model="ir.ui.view">
<field name="name">commercial.invoice.search</field>
<field name="model">commercial.invoice</field>
<field name="arch" type="xml">
<search string="Search Commercial Invoices">
<field name="name"/>
<field name="partner_id"/>
<field name="invoice_ids"/>
<filter string="Draft" name="draft" domain="[('state','=','draft')]"/>
<filter string="Done" name="done" domain="[('state','=','done')]"/>
<group expand="0" string="Group By">
<filter string="Status" name="status" context="{'group_by':'state'}"/>
<filter string="Partner" name="partner" context="{'group_by':'partner_id'}"/>
<filter string="Date" name="date" context="{'group_by':'date'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_commercial_invoice" model="ir.actions.act_window">
<field name="name">Commercial Invoices</field>
<field name="res_model">commercial.invoice</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_draft': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first commercial invoice
</p>
</field>
</record>
<!-- Menu -->
<menuitem id="menu_commercial_invoice_root"
name="Commercial Invoices"
parent="account.menu_finance"
sequence="5"/>
<menuitem id="menu_commercial_invoice"
name="Commercial Invoices"
parent="menu_commercial_invoice_root"
action="action_commercial_invoice"
sequence="1"/>
</odoo>

View file

@ -0,0 +1 @@
from . import models

View file

@ -0,0 +1,14 @@
{
'name': 'Partner Default Language by Location',
'version': '18.0.1.0.0',
'category': 'Tools',
'summary': 'Automatically set partner language based on state/country',
'author': 'Bemade',
'website': 'https://www.bemade.org',
'depends': ['base'],
'data': [],
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

View file

@ -0,0 +1 @@
from . import res_partner

View file

@ -0,0 +1,34 @@
from odoo import api, models
class ResPartner(models.Model):
_inherit = 'res.partner'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
# Only set language if it's not already specified
if not vals.get('lang'):
state = vals.get('state_id')
country = vals.get('country_id')
if state:
state_rec = self.env['res.country.state'].browse(state)
# Check if the state is Quebec
if state_rec.code == 'QC' and state_rec.country_id.code == 'CA':
vals['lang'] = 'fr_CA'
else:
vals['lang'] = 'en_US'
else:
vals['lang'] = 'en_US'
return super().create(vals_list)
@api.onchange('state_id', 'country_id')
def _onchange_location_set_lang(self):
# Only suggest language change if it's a new record (id not set yet)
if not self.id and not self.lang:
if self.state_id and self.state_id.code == 'QC' and self.state_id.country_id.code == 'CA':
self.lang = 'fr_CA'
else:
self.lang = 'en_US'

View file

@ -0,0 +1,27 @@
<![CDATA[<section class="oe_container">
<div class="oe_row oe_spaced">
<h2 class="oe_slogan" style="color:#875A7B;">Partner Default Language by Location</h2>
<h3 class="oe_slogan">Automatic Language Assignment for Partners</h3>
<div class="oe_demo oe_picture oe_screenshot">
<p class="oe_mt32 text-center">
This module automatically sets the default language for new partners based on their location.
</p>
</div>
</div>
</section>
<section class="oe_container">
<div class="oe_row oe_spaced">
<div class="oe_span12">
<h2 class="oe_slogan" style="color:#875A7B;">Features</h2>
<div class="oe_demo oe_picture oe_screenshot">
<ul>
<li>Automatically sets French (fr_CA) for partners in Quebec</li>
<li>Defaults to English (en_US) for all other locations</li>
<li>Works on partner creation and when updating location fields</li>
<li>Respects manually set language preferences</li>
</ul>
</div>
</div>
</div>
</section>]]>