diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93fcd87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +**/__pycache__/ +*.pyc diff --git a/fusion_claims/models/res_partner.py b/fusion_claims/models/res_partner.py index 9c60e4a..5803494 100644 --- a/fusion_claims/models/res_partner.py +++ b/fusion_claims/models/res_partner.py @@ -45,9 +45,8 @@ class ResPartner(models.Model): # ========================================================================== x_fc_odsp_member_id = fields.Char( string='ODSP Member ID', - size=9, tracking=True, - help='9-digit Ontario Disability Support Program Member ID', + help='Ontario Disability Support Program Member ID', ) x_fc_case_worker_id = fields.Many2one( 'res.partner', @@ -69,6 +68,17 @@ class ResPartner(models.Model): store=True, ) + # ========================================================================== + # AUTHORIZER FIELDS + # ========================================================================== + x_fc_authorizer_number = fields.Char( + string='ADP Authorizer Number', + tracking=True, + index=True, + help='ADP Registration Number for this authorizer (e.g. OT). ' + 'Used to auto-link authorizers when processing ADP XML files.', + ) + @api.depends('x_fc_contact_type') def _compute_is_odsp_office(self): for partner in self: diff --git a/fusion_claims/models/xml_parser.py b/fusion_claims/models/xml_parser.py index 9c3fc3c..1a879de 100644 --- a/fusion_claims/models/xml_parser.py +++ b/fusion_claims/models/xml_parser.py @@ -66,6 +66,10 @@ class FusionXmlParser(models.AbstractModel): # Step 3: Create/update profile profile = self._find_or_create_profile(model_vals, sale_order) + # Step 3b: Auto-link authorizer on sale order by ADP number + if sale_order: + self._link_authorizer_by_adp_number(model_vals, sale_order) + # Step 4: Create application data record model_vals['profile_id'] = profile.id model_vals['sale_order_id'] = sale_order.id if sale_order else False @@ -637,6 +641,39 @@ class FusionXmlParser(models.AbstractModel): # ------------------------------------------------------------------ # PROFILE MANAGEMENT # ------------------------------------------------------------------ + def _link_authorizer_by_adp_number(self, vals, sale_order): + """Auto-link the authorizer contact on the sale order using the ADP number from XML.""" + adp_number = (vals.get('authorizer_adp_number') or '').strip() + if not adp_number or adp_number.upper() in ('NA', 'N/A', ''): + return + + if sale_order.x_fc_authorizer_id: + return + + Partner = self.env['res.partner'] + authorizer = Partner.search([ + ('x_fc_authorizer_number', '=', adp_number), + ], limit=1) + + if not authorizer: + first = (vals.get('authorizer_first_name') or '').strip() + last = (vals.get('authorizer_last_name') or '').strip() + if first and last: + authorizer = Partner.search([ + '|', + ('name', 'ilike', f'{first} {last}'), + ('name', 'ilike', f'{last}, {first}'), + ], limit=1) + if authorizer and not authorizer.x_fc_authorizer_number: + authorizer.write({'x_fc_authorizer_number': adp_number}) + + if authorizer: + sale_order.write({'x_fc_authorizer_id': authorizer.id}) + _logger.info( + 'Auto-linked authorizer %s (ADP# %s) to SO %s', + authorizer.name, adp_number, sale_order.name, + ) + def _find_or_create_profile(self, vals, sale_order=None): """Find or create a client profile from parsed application data.""" Profile = self.env['fusion.client.profile'] diff --git a/fusion_claims/views/res_partner_views.xml b/fusion_claims/views/res_partner_views.xml index 824238d..1a0dfc4 100644 --- a/fusion_claims/views/res_partner_views.xml +++ b/fusion_claims/views/res_partner_views.xml @@ -16,6 +16,13 @@ + + + + + 1: + hint = matching_partners[0].name if matching_partners else search_hint + return self._open_fusion_match_wizard(hint) + + self.write({ + 'fusion_sale_ids': [(4, matching_sales.id)], + 'fusion_marked_for_ids': [(4, matching_partners[0].id)], + }) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Success'), + 'message': _('Linked to Sale Order: %s') % matching_sales.name, + 'type': 'success', + 'sticky': False, + }, + } + + def action_fusion_batch_match(self): + """Batch match multiple POs to Sale Orders based on x_marked_for field.""" + matched = 0 + skipped = 0 + errors = [] + + for po in self: + marked_for_value = getattr(po, 'x_marked_for', None) + if not marked_for_value: + skipped += 1 + continue + + marked_for_str = str(marked_for_value) + matching_partners = None + + partner_id_match = re.search(r'res\.partner\((\d+)', marked_for_str) + if partner_id_match: + partner_id = int(partner_id_match.group(1)) + matching_partners = self.env['res.partner'].browse(partner_id).exists() + + if not matching_partners: + matching_partners = self.env['res.partner'].search([ + '|', + ('name', 'ilike', marked_for_str), + ('display_name', 'ilike', marked_for_str), + ]) + + if not matching_partners: + errors.append(_("PO %s: No customer found for '%s'") % (po.name, marked_for_str)) + continue + + matching_sales = self.env['sale.order'].search([ + ('partner_id', 'in', matching_partners.ids), + ]) + + if not matching_sales: + errors.append(_("PO %s: No SO found for '%s'") % (po.name, matching_partners[0].name)) + continue + + if len(matching_sales) > 1: + errors.append( + _("PO %s: Multiple SOs (%d) for '%s'") % (po.name, len(matching_sales), matching_partners[0].name) + ) + continue + + po.write({ + 'fusion_sale_ids': [(4, matching_sales.id)], + 'fusion_marked_for_ids': [(4, matching_partners[0].id)], + }) + matched += 1 + + message = _("Matched: %d, Skipped: %d") % (matched, skipped) + if errors: + message += "\n" + "\n".join(errors[:5]) + if len(errors) > 5: + message += _("\n... and %d more errors") % (len(errors) - 5) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Batch Match Complete'), + 'message': message, + 'type': 'info' if matched > 0 else 'warning', + 'sticky': True, + }, + } + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + x_fc_account_number = fields.Char( + string='Account Number', + tracking=True, + help='Vendor/supplier account number', + ) diff --git a/fusion_so_to_po/security/ir.model.access.csv b/fusion_so_to_po/security/ir.model.access.csv new file mode 100644 index 0000000..405c3c7 --- /dev/null +++ b/fusion_so_to_po/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_so_po_wiz,access_fusion_so_po_wiz,model_fusion_so_po_wiz,base.group_user,1,1,1,1 +access_fusion_so_po_line_wiz,access_fusion_so_po_line_wiz,model_fusion_so_po_line_wiz,base.group_user,1,1,1,1 +access_fusion_match_so_wiz,access_fusion_match_so_wiz,model_fusion_match_so_wiz,base.group_user,1,1,1,1 diff --git a/fusion_so_to_po/static/.DS_Store b/fusion_so_to_po/static/.DS_Store new file mode 100644 index 0000000..7f42bcf Binary files /dev/null and b/fusion_so_to_po/static/.DS_Store differ diff --git a/fusion_so_to_po/static/description/icon.png b/fusion_so_to_po/static/description/icon.png new file mode 100644 index 0000000..3a38e2d Binary files /dev/null and b/fusion_so_to_po/static/description/icon.png differ diff --git a/fusion_so_to_po/views/fusion_so_to_po_views.xml b/fusion_so_to_po/views/fusion_so_to_po_views.xml new file mode 100644 index 0000000..71f689c --- /dev/null +++ b/fusion_so_to_po/views/fusion_so_to_po_views.xml @@ -0,0 +1,55 @@ + + + + + + + fusion.sale.order.form.inherit + sale.order + + + + + + + + + + + fusion.purchase.order.form.inherit + purchase.order + + + + + + + + + + + + + + + + + + + diff --git a/fusion_so_to_po/wizard/__init__.py b/fusion_so_to_po/wizard/__init__.py new file mode 100644 index 0000000..27b09ad --- /dev/null +++ b/fusion_so_to_po/wizard/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import fusion_purchase_order_wiz +from . import fusion_match_sale_order_wiz diff --git a/fusion_so_to_po/wizard/fusion_match_sale_order_wiz.py b/fusion_so_to_po/wizard/fusion_match_sale_order_wiz.py new file mode 100644 index 0000000..3cbe0f4 --- /dev/null +++ b/fusion_so_to_po/wizard/fusion_match_sale_order_wiz.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + + +class FusionMatchSaleOrderWiz(models.TransientModel): + _name = 'fusion.match.so.wiz' + _description = 'Fusion - Match Sale Order Wizard' + + fusion_po_id = fields.Many2one( + 'purchase.order', + string='Purchase Order', + required=True, + readonly=True, + ) + fusion_sale_order_id = fields.Many2one( + 'sale.order', + string='Sale Order', + help='Select the Sale Order to link to this Purchase Order', + ) + fusion_search_hint = fields.Char( + string='Search Hint', + readonly=True, + help='Suggested search term based on Marked For field', + ) + + def action_fusion_confirm(self): + self.ensure_one() + + if not self.fusion_sale_order_id: + raise UserError(_("Please select a Sale Order.")) + + customer = self.fusion_sale_order_id.partner_id + + self.fusion_po_id.write({ + 'fusion_sale_ids': [(4, self.fusion_sale_order_id.id)], + 'fusion_marked_for_ids': [(4, customer.id)], + }) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Success'), + 'message': _('Linked Purchase Order %s to Sale Order %s') % ( + self.fusion_po_id.name, + self.fusion_sale_order_id.name, + ), + 'type': 'success', + 'sticky': False, + 'next': {'type': 'ir.actions.act_window_close'}, + }, + } diff --git a/fusion_so_to_po/wizard/fusion_match_sale_order_wiz.xml b/fusion_so_to_po/wizard/fusion_match_sale_order_wiz.xml new file mode 100644 index 0000000..ed5bb28 --- /dev/null +++ b/fusion_so_to_po/wizard/fusion_match_sale_order_wiz.xml @@ -0,0 +1,43 @@ + + + + + + fusion.match.so.wiz.form + fusion.match.so.wiz + +
+ + + +

+ Search and select the Sale Order to link to this Purchase Order. +

+
+ + + + + + +
+
+ +
+
+ + + Match Sale Order + fusion.match.so.wiz + ir.actions.act_window + form + new + + +
+
diff --git a/fusion_so_to_po/wizard/fusion_purchase_order_wiz.py b/fusion_so_to_po/wizard/fusion_purchase_order_wiz.py new file mode 100644 index 0000000..5c6450e --- /dev/null +++ b/fusion_so_to_po/wizard/fusion_purchase_order_wiz.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError +from collections import defaultdict + + +class FusionPurchaseOrderWiz(models.TransientModel): + _name = 'fusion.so.po.wiz' + _description = 'Fusion - Create Purchase Order Wizard' + + fusion_so_id = fields.Many2one('sale.order', string='Sale Order') + fusion_line_ids = fields.One2many( + 'fusion.so.po.line.wiz', + 'fusion_wizard_id', + string='Products', + ) + fusion_batch_vendor_id = fields.Many2one( + 'res.partner', + string='Assign Vendor to All', + domain="[('is_company', '=', True)]", + help='Select a vendor here to assign it to ALL products at once', + ) + + @api.onchange('fusion_batch_vendor_id') + def _onchange_fusion_batch_vendor_id(self): + if self.fusion_batch_vendor_id: + selected_lines = self.fusion_line_ids.filtered(lambda l: l.selected) + lines_to_update = selected_lines if selected_lines else self.fusion_line_ids + + for line in lines_to_update: + line.vendor_id = self.fusion_batch_vendor_id + if line.product_id: + seller = line.product_id.seller_ids.filtered( + lambda s: s.partner_id.id == self.fusion_batch_vendor_id.id + )[:1] + if seller and seller.price: + line.price_unit = seller.price + else: + line.price_unit = line.product_id.standard_price + line.price_subtotal = line.price_unit * line.product_uom_qty + + for line in selected_lines: + line.selected = False + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_ids = self._context.get('active_ids') + if not active_ids: + return res + + sale_order = self.env['sale.order'].browse(active_ids[0]) + if not sale_order.order_line: + raise UserError(_("Please add some valid sale order lines...!")) + + res['fusion_so_id'] = sale_order.id + + product_lines = [] + for line in sale_order.order_line: + if line.display_type in ('line_section', 'line_note'): + continue + + description = line.name or line.product_id.display_name or line.product_id.name or 'Product' + + cost_price = line.product_id.standard_price + product_lines.append((0, 0, { + 'product_id': line.product_id.id, + 'description': description, + 'product_uom_qty': line.product_uom_qty, + 'price_unit': cost_price, + 'product_uom': line.product_uom_id.id, + 'price_subtotal': cost_price * line.product_uom_qty, + 'fusion_so_line_id': line.id, + 'vendor_id': False, + })) + + res['fusion_line_ids'] = product_lines + return res + + def fusion_create_po(self): + self.ensure_one() + + vendor_products = defaultdict(list) + for product_line in self.fusion_line_ids: + if product_line.vendor_id: + vendor_products[product_line.vendor_id.id].append(product_line) + + if not vendor_products: + raise UserError(_("Please assign at least one product to a vendor before creating Purchase Orders.")) + + now = fields.Datetime.now() + created_pos = self.env['purchase.order'] + customer = self.fusion_so_id.partner_id + + for vendor_id, product_lines in vendor_products.items(): + partner = self.env['res.partner'].browse(vendor_id) + fpos = self.env['account.fiscal.position'].sudo()._get_fiscal_position(partner) + + date_planned = self.fusion_so_id.commitment_date or now + + purchase_order = self.env['purchase.order'].create({ + 'partner_id': partner.id, + 'partner_ref': partner.ref, + 'company_id': self.fusion_so_id.company_id.id, + 'currency_id': self.env.company.currency_id.id, + 'dest_address_id': False, + 'origin': self.fusion_so_id.name, + 'payment_term_id': partner.property_supplier_payment_term_id.id, + 'date_order': now, + 'fiscal_position_id': fpos.id, + 'fusion_sale_ids': [(4, self.fusion_so_id.id)], + 'fusion_marked_for_ids': [(4, customer.id)], + }) + + for product_line in product_lines: + values = product_line._prepare_fusion_po_line(purchase_order, date_planned) + self.env['purchase.order.line'].create(values) + + created_pos |= purchase_order + + if len(created_pos) == 1: + return { + 'name': _('Purchase Order'), + 'view_mode': 'form', + 'res_model': 'purchase.order', + 'view_id': self.env.ref('purchase.purchase_order_form').id, + 'res_id': created_pos.id, + 'type': 'ir.actions.act_window', + 'target': 'current', + } + return { + 'name': _('Purchase Orders'), + 'view_mode': 'list,form', + 'res_model': 'purchase.order', + 'domain': [('id', 'in', created_pos.ids)], + 'type': 'ir.actions.act_window', + 'target': 'current', + } + + +class FusionPurchaseProductWiz(models.TransientModel): + _name = 'fusion.so.po.line.wiz' + _description = 'Fusion - Purchase Product Wizard Line' + + fusion_wizard_id = fields.Many2one('fusion.so.po.wiz', string='Wizard', ondelete='cascade') + selected = fields.Boolean(string='Select', default=False) + fusion_so_line_id = fields.Many2one('sale.order.line', string='SO Line') + product_id = fields.Many2one('product.product', string='Product') + product_uom = fields.Many2one('uom.uom', string='Unit of Measure') + description = fields.Char(string='Description') + product_uom_qty = fields.Float(string='Quantity') + price_unit = fields.Float(string='Unit Price') + price_subtotal = fields.Float(string='Subtotal') + vendor_id = fields.Many2one( + 'res.partner', + string='Vendor', + domain="[('is_company', '=', True)]", + ) + + @api.onchange('vendor_id') + def _onchange_vendor_id(self): + if self.vendor_id and self.product_id: + seller = self.product_id.seller_ids.filtered( + lambda s: s.partner_id.id == self.vendor_id.id + )[:1] + if seller and seller.price: + self.price_unit = seller.price + else: + self.price_unit = self.product_id.standard_price + self.price_subtotal = self.price_unit * self.product_uom_qty + + def _prepare_fusion_po_line(self, purchase_order, date_planned): + self.ensure_one() + product_uom = self.product_uom or self.product_id.uom_id + + name = self.description + if not name: + name = self.product_id.display_name or self.product_id.name or 'Product' + if self.product_id.default_code and self.product_id.default_code not in name: + name = '[%s] %s' % (self.product_id.default_code, name) + + return { + 'name': name, + 'product_qty': self.product_uom_qty, + 'product_id': self.product_id.id, + 'product_uom_id': product_uom.id, + 'price_unit': self.price_unit, + 'date_planned': date_planned, + 'order_id': purchase_order.id, + } diff --git a/fusion_so_to_po/wizard/fusion_purchase_order_wiz.xml b/fusion_so_to_po/wizard/fusion_purchase_order_wiz.xml new file mode 100644 index 0000000..79d8eec --- /dev/null +++ b/fusion_so_to_po/wizard/fusion_purchase_order_wiz.xml @@ -0,0 +1,57 @@ + + + + + + fusion.so.po.wiz.form + fusion.so.po.wiz + +
+ + + + + + + +

+ Tip: Check the boxes below to select products, then pick a vendor above to assign to selected only. + If nothing is selected, vendor applies to all. +

+
+
+ + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + Create Purchase Order + fusion.so.po.wiz + ir.actions.act_window + form + new + + +
+