317 lines
13 KiB
Python
317 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
|
|
import logging
|
|
from odoo import models, fields, api
|
|
from odoo.exceptions import UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FusionInterCompanyTransfer(models.Model):
|
|
_name = 'fusion.inter.company.transfer'
|
|
_description = 'Inter-Company Inventory Transfer'
|
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
_order = 'create_date desc'
|
|
_rec_name = 'display_name'
|
|
|
|
display_name = fields.Char(compute='_compute_display_name')
|
|
config_id = fields.Many2one(
|
|
'fusion.sync.config', string='Remote Instance',
|
|
required=True, ondelete='restrict',
|
|
domain=[('state', '=', 'connected')])
|
|
product_id = fields.Many2one(
|
|
'product.product', string='Product',
|
|
required=True, ondelete='restrict')
|
|
product_mapping_id = fields.Many2one(
|
|
'fusion.product.sync.mapping', string='Product Mapping',
|
|
compute='_compute_product_mapping', store=True)
|
|
quantity = fields.Float(string='Quantity', required=True, default=1.0)
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('requested', 'Requested'),
|
|
('so_created', 'Remote SO Created'),
|
|
('po_created', 'Local PO Created'),
|
|
('invoiced', 'Invoiced'),
|
|
('transferred', 'Transferred'),
|
|
('done', 'Done'),
|
|
('error', 'Error'),
|
|
('cancelled', 'Cancelled'),
|
|
], string='Status', default='draft', required=True,
|
|
tracking=True, index=True)
|
|
|
|
remote_so_id = fields.Integer(string='Remote SO ID', readonly=True)
|
|
remote_so_name = fields.Char(string='Remote SO Reference', readonly=True)
|
|
local_po_id = fields.Many2one(
|
|
'purchase.order', string='Local PO', readonly=True)
|
|
remote_invoice_id = fields.Integer(string='Remote Invoice ID', readonly=True)
|
|
local_bill_id = fields.Many2one(
|
|
'account.move', string='Local Vendor Bill', readonly=True)
|
|
task_id = fields.Many2one(
|
|
'fusion.technician.task', string='Delivery Task', readonly=True)
|
|
warehouse_inventory_id = fields.Many2one(
|
|
'fusion.warehouse.inventory', string='Warehouse Record', readonly=True)
|
|
error_step = fields.Char(string='Failed Step', readonly=True)
|
|
|
|
requested_by = fields.Many2one('res.users', string='Requested By',
|
|
default=lambda self: self.env.uid)
|
|
notes = fields.Text(string='Notes')
|
|
|
|
@api.depends('product_id', 'config_id')
|
|
def _compute_display_name(self):
|
|
for rec in self:
|
|
product = rec.product_id.name or 'No Product'
|
|
remote = rec.config_id.name or 'No Remote'
|
|
rec.display_name = f'{product} <- {remote}'
|
|
|
|
@api.depends('product_id', 'config_id')
|
|
def _compute_product_mapping(self):
|
|
Mapping = self.env['fusion.product.sync.mapping']
|
|
for rec in self:
|
|
if rec.product_id and rec.config_id:
|
|
mapping = Mapping.search([
|
|
('config_id', '=', rec.config_id.id),
|
|
('local_product_id', '=', rec.product_id.product_tmpl_id.id),
|
|
], limit=1)
|
|
rec.product_mapping_id = mapping.id if mapping else False
|
|
else:
|
|
rec.product_mapping_id = False
|
|
|
|
# ── Manual Step-by-Step Actions (preserved) ──
|
|
|
|
def action_request(self):
|
|
for rec in self.filtered(lambda r: r.state == 'draft'):
|
|
if not rec.product_mapping_id:
|
|
raise UserError(
|
|
f'No sync mapping found for {rec.product_id.name} '
|
|
f'on {rec.config_id.name}. Sync products first.')
|
|
rec.state = 'requested'
|
|
|
|
def action_create_remote_so(self):
|
|
for rec in self.filtered(lambda r: r.state == 'requested'):
|
|
try:
|
|
partner_name = (
|
|
rec.config_id.local_company_name
|
|
or rec.env.company.name)
|
|
remote_so_id, so_name = rec.config_id._create_remote_sale_order(
|
|
rec.product_mapping_id, rec.quantity, partner_name)
|
|
rec.write({
|
|
'remote_so_id': remote_so_id,
|
|
'remote_so_name': so_name,
|
|
'state': 'so_created',
|
|
})
|
|
_logger.info('Remote SO %s created for transfer %s',
|
|
so_name, rec.display_name)
|
|
except Exception as e:
|
|
raise UserError(f'Failed to create remote SO: {e}')
|
|
|
|
def action_create_local_po(self):
|
|
for rec in self.filtered(lambda r: r.state == 'so_created'):
|
|
partner = rec.config_id.remote_partner_id
|
|
if not partner:
|
|
partner = self.env['res.partner'].search([
|
|
('name', 'ilike', rec.config_id.name),
|
|
], limit=1)
|
|
if not partner:
|
|
partner = self.env['res.partner'].create({
|
|
'name': rec.config_id.name,
|
|
'supplier_rank': 1,
|
|
})
|
|
|
|
po = self.env['purchase.order'].create({
|
|
'partner_id': partner.id,
|
|
'order_line': [(0, 0, {
|
|
'product_id': rec.product_id.id,
|
|
'product_qty': rec.quantity,
|
|
'price_unit': rec.product_id.standard_price,
|
|
})],
|
|
'origin': f'ICT from {rec.config_id.name} (SO {rec.remote_so_name or rec.remote_so_id})',
|
|
})
|
|
po.button_confirm()
|
|
|
|
rec.write({
|
|
'local_po_id': po.id,
|
|
'state': 'po_created',
|
|
})
|
|
_logger.info('Local PO %s created for transfer %s',
|
|
po.name, rec.display_name)
|
|
|
|
def action_create_invoice(self):
|
|
for rec in self.filtered(lambda r: r.state == 'po_created'):
|
|
try:
|
|
remote_inv_id = rec.config_id._create_remote_invoice(rec.remote_so_id)
|
|
rec.write({
|
|
'remote_invoice_id': remote_inv_id or 0,
|
|
'state': 'invoiced',
|
|
})
|
|
except Exception as e:
|
|
_logger.warning('Remote invoice creation failed: %s', e)
|
|
rec.state = 'invoiced'
|
|
|
|
def action_create_vendor_bill(self):
|
|
"""Create a local vendor bill from the PO (draft, for accounting review)."""
|
|
for rec in self.filtered(lambda r: r.state == 'invoiced' and r.local_po_id):
|
|
if rec.local_bill_id:
|
|
continue
|
|
action = rec.local_po_id.action_create_invoice()
|
|
if action and action.get('res_id'):
|
|
rec.local_bill_id = action['res_id']
|
|
else:
|
|
bills = self.env['account.move'].search([
|
|
('purchase_order_count', '>', 0),
|
|
('ref', 'ilike', rec.local_po_id.name),
|
|
], limit=1, order='id desc')
|
|
if bills:
|
|
rec.local_bill_id = bills.id
|
|
|
|
def action_create_delivery_task(self):
|
|
for rec in self.filtered(lambda r: r.state in ('po_created', 'invoiced')):
|
|
try:
|
|
task = self.env['fusion.technician.task'].create({
|
|
'name': f'Warehouse Transfer: {rec.product_id.name} x{rec.quantity}',
|
|
'task_type': 'delivery',
|
|
'description': (
|
|
f'Transfer {rec.quantity}x {rec.product_id.name} '
|
|
f'from {rec.config_id.name} shared warehouse.\n'
|
|
f'Remote SO: {rec.remote_so_name or rec.remote_so_id}\n'
|
|
f'Local PO: {rec.local_po_id.name if rec.local_po_id else "N/A"}'
|
|
),
|
|
})
|
|
rec.task_id = task.id
|
|
except Exception as e:
|
|
_logger.warning('Task creation failed (fusion_tasks may not be configured): %s', e)
|
|
|
|
def action_mark_transferred(self):
|
|
for rec in self.filtered(lambda r: r.state == 'invoiced'):
|
|
if rec.warehouse_inventory_id:
|
|
rec.warehouse_inventory_id.action_mark_transferred()
|
|
rec.state = 'transferred'
|
|
|
|
def action_complete(self):
|
|
for rec in self.filtered(lambda r: r.state == 'transferred'):
|
|
rec.state = 'done'
|
|
|
|
def action_cancel(self):
|
|
for rec in self.filtered(lambda r: r.state not in ('done', 'cancelled')):
|
|
if rec.warehouse_inventory_id:
|
|
rec.warehouse_inventory_id.action_release()
|
|
rec.state = 'cancelled'
|
|
|
|
# ── One-Click Automated Transfer ──
|
|
|
|
def action_execute_transfer(self):
|
|
"""Execute all transfer steps in one click:
|
|
1. Create SO on remote
|
|
2. Create Invoice on remote
|
|
3. Create PO locally
|
|
4. Confirm PO
|
|
5. Create Vendor Bill (draft)
|
|
6. Mark done
|
|
"""
|
|
for rec in self:
|
|
if rec.state not in ('draft', 'requested'):
|
|
continue
|
|
|
|
if not rec.product_mapping_id:
|
|
rec.write({
|
|
'state': 'error',
|
|
'error_step': 'validation',
|
|
'notes': (rec.notes or '') + '\nNo sync mapping found. Sync products first.',
|
|
})
|
|
continue
|
|
|
|
config = rec.config_id
|
|
partner_name = config.local_company_name or rec.env.company.name
|
|
|
|
# Step 1: Remote SO
|
|
try:
|
|
remote_so_id, so_name = config._create_remote_sale_order(
|
|
rec.product_mapping_id, rec.quantity, partner_name)
|
|
rec.write({
|
|
'remote_so_id': remote_so_id,
|
|
'remote_so_name': so_name,
|
|
'state': 'so_created',
|
|
})
|
|
rec.message_post(body=f'Remote SO {so_name} created.')
|
|
except Exception as e:
|
|
rec.write({
|
|
'state': 'error',
|
|
'error_step': 'remote_so',
|
|
'notes': (rec.notes or '') + f'\nFailed at Remote SO: {e}',
|
|
})
|
|
_logger.error('ICT %s: Remote SO failed: %s', rec.id, e)
|
|
continue
|
|
|
|
# Step 2: Remote Invoice
|
|
try:
|
|
remote_inv_id = config._create_remote_invoice(remote_so_id)
|
|
rec.write({
|
|
'remote_invoice_id': remote_inv_id or 0,
|
|
})
|
|
rec.message_post(body=f'Remote Invoice created (ID: {remote_inv_id}).')
|
|
except Exception as e:
|
|
_logger.warning('ICT %s: Remote Invoice failed (non-fatal): %s', rec.id, e)
|
|
rec.message_post(body=f'Remote Invoice skipped: {e}')
|
|
|
|
# Step 3: Local PO
|
|
try:
|
|
partner = config.remote_partner_id
|
|
if not partner:
|
|
partner = self.env['res.partner'].search([
|
|
('name', 'ilike', config.name),
|
|
], limit=1)
|
|
if not partner:
|
|
partner = self.env['res.partner'].create({
|
|
'name': config.name,
|
|
'supplier_rank': 1,
|
|
})
|
|
|
|
po = self.env['purchase.order'].create({
|
|
'partner_id': partner.id,
|
|
'order_line': [(0, 0, {
|
|
'product_id': rec.product_id.id,
|
|
'product_qty': rec.quantity,
|
|
'price_unit': rec.product_id.standard_price,
|
|
})],
|
|
'origin': f'ICT from {config.name} (SO {rec.remote_so_name or rec.remote_so_id})',
|
|
})
|
|
po.button_confirm()
|
|
rec.write({
|
|
'local_po_id': po.id,
|
|
'state': 'po_created',
|
|
})
|
|
rec.message_post(body=f'Local PO {po.name} created and confirmed.')
|
|
except Exception as e:
|
|
rec.write({
|
|
'state': 'error',
|
|
'error_step': 'local_po',
|
|
'notes': (rec.notes or '') + f'\nFailed at Local PO: {e}',
|
|
})
|
|
_logger.error('ICT %s: Local PO failed: %s', rec.id, e)
|
|
continue
|
|
|
|
# Step 4: Local Vendor Bill (draft)
|
|
try:
|
|
action = po.action_create_invoice()
|
|
if action and action.get('res_id'):
|
|
rec.local_bill_id = action['res_id']
|
|
rec.message_post(body='Local Vendor Bill created (draft).')
|
|
except Exception as e:
|
|
_logger.warning('ICT %s: Vendor bill creation failed (non-fatal): %s', rec.id, e)
|
|
|
|
# Step 5: Done
|
|
rec.write({
|
|
'state': 'done',
|
|
})
|
|
rec.message_post(body='Transfer completed automatically.')
|
|
_logger.info('ICT %s: One-click transfer completed', rec.id)
|
|
|
|
def action_retry(self):
|
|
"""Reset an errored transfer back to draft for retry."""
|
|
for rec in self.filtered(lambda r: r.state == 'error'):
|
|
rec.write({
|
|
'state': 'draft',
|
|
'error_step': False,
|
|
})
|