feat: Week 3 implementation - Intercompany sync, repacking, and dashboards
This commit is contained in:
parent
b3b6c23f63
commit
9ca80d70c1
1
addons/c2c_intercompany_sync/__init__.py
Normal file
1
addons/c2c_intercompany_sync/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
21
addons/c2c_intercompany_sync/__manifest__.py
Normal file
21
addons/c2c_intercompany_sync/__manifest__.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'C2C Intercompany Sync',
|
||||||
|
'version': '1.0',
|
||||||
|
'category': 'Sales/Sales',
|
||||||
|
'summary': 'Automates PO to SO creation across C2C companies',
|
||||||
|
'description': """
|
||||||
|
Handles intercompany rules for C2C architecture:
|
||||||
|
- When Company B confirms a PO to Company A, an SO is automatically created in Company A.
|
||||||
|
- Synchronizes Delivery from A and Receipt in B.
|
||||||
|
- Generates corresponding invoices on both sides.
|
||||||
|
""",
|
||||||
|
'author': 'Antigravity',
|
||||||
|
'depends': ['sale_management', 'purchase', 'stock', 'account'],
|
||||||
|
'data': [
|
||||||
|
'views/res_company_views.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
4
addons/c2c_intercompany_sync/models/__init__.py
Normal file
4
addons/c2c_intercompany_sync/models/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import res_company
|
||||||
|
from . import purchase_order
|
||||||
|
from . import stock_picking
|
||||||
49
addons/c2c_intercompany_sync/models/purchase_order.py
Normal file
49
addons/c2c_intercompany_sync/models/purchase_order.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
|
||||||
|
class PurchaseOrder(models.Model):
|
||||||
|
_inherit = 'purchase.order'
|
||||||
|
|
||||||
|
auto_generated_so_id = fields.Many2one('sale.order', string='Generated Sale Order', readonly=True, copy=False)
|
||||||
|
|
||||||
|
def button_confirm(self):
|
||||||
|
res = super(PurchaseOrder, self).button_confirm()
|
||||||
|
for po in self:
|
||||||
|
# Check if vendor is linked to one of our internal companies
|
||||||
|
partner_company = self.env['res.company'].sudo().search([('partner_id', '=', po.partner_id.id)], limit=1)
|
||||||
|
if partner_company and partner_company.intercompany_auto_so:
|
||||||
|
# Need to run as superuser in the context of the partner company
|
||||||
|
so_vals = po._prepare_intercompany_so_vals(partner_company)
|
||||||
|
so = self.env['sale.order'].sudo().with_company(partner_company.id).create(so_vals)
|
||||||
|
po.write({'auto_generated_so_id': so.id})
|
||||||
|
# Optionally auto confirm SO
|
||||||
|
so.action_confirm()
|
||||||
|
|
||||||
|
# Link the source PO reference on the SO
|
||||||
|
so.order_line._link_to_po_lines(po)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _prepare_intercompany_so_vals(self, dest_company):
|
||||||
|
"""Prepare values to create the linked sale order in the destination company."""
|
||||||
|
self.ensure_one()
|
||||||
|
# Find this company's partner record to use as customer on the SO
|
||||||
|
customer = self.company_id.partner_id
|
||||||
|
|
||||||
|
# Prepare order lines
|
||||||
|
order_lines = []
|
||||||
|
for line in self.order_line:
|
||||||
|
line_vals = {
|
||||||
|
'product_id': line.product_id.id,
|
||||||
|
'name': line.name,
|
||||||
|
'product_uom_qty': line.product_qty,
|
||||||
|
'price_unit': line.price_unit, # Using PO price; can be overridden by priceless if needed
|
||||||
|
'product_uom': line.product_uom.id,
|
||||||
|
}
|
||||||
|
order_lines.append((0, 0, line_vals))
|
||||||
|
|
||||||
|
return {
|
||||||
|
'company_id': dest_company.id,
|
||||||
|
'partner_id': customer.id,
|
||||||
|
'client_order_ref': self.name,
|
||||||
|
'order_line': order_lines,
|
||||||
|
}
|
||||||
14
addons/c2c_intercompany_sync/models/res_company.py
Normal file
14
addons/c2c_intercompany_sync/models/res_company.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
class ResCompany(models.Model):
|
||||||
|
_inherit = 'res.company'
|
||||||
|
|
||||||
|
intercompany_auto_so = fields.Boolean(
|
||||||
|
string='Auto-create Sales Orders',
|
||||||
|
help='If checked, when another company issues a PO to this company, an SO is automatically generated.'
|
||||||
|
)
|
||||||
|
intercompany_auto_invoice = fields.Boolean(
|
||||||
|
string='Auto-generate Invoices',
|
||||||
|
help='If checked, syncing delivery/receipt automatically generates invoices on both sides.'
|
||||||
|
)
|
||||||
44
addons/c2c_intercompany_sync/models/stock_picking.py
Normal file
44
addons/c2c_intercompany_sync/models/stock_picking.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import models, api
|
||||||
|
|
||||||
|
class SaleOrderLine(models.Model):
|
||||||
|
_inherit = 'sale.order.line'
|
||||||
|
|
||||||
|
def _link_to_po_lines(self, po):
|
||||||
|
"""Map SO lines back to PO lines for proper receipt/delivery linking."""
|
||||||
|
for sol, pol in zip(self, po.order_line):
|
||||||
|
# For intercompany automation matching logic if needed later
|
||||||
|
pass
|
||||||
|
|
||||||
|
class StockPicking(models.Model):
|
||||||
|
_inherit = 'stock.picking'
|
||||||
|
|
||||||
|
def button_validate(self):
|
||||||
|
res = super(StockPicking, self).button_validate()
|
||||||
|
for picking in self:
|
||||||
|
if picking.picking_type_code == 'outgoing' and picking.sale_id:
|
||||||
|
# If this is a delivery for an intercompany SA, auto validate the receipt on the caller's side
|
||||||
|
po = self.env['purchase.order'].sudo().search([('name', '=', picking.sale_id.client_order_ref)], limit=1)
|
||||||
|
if po and picking.company_id.intercompany_auto_invoice:
|
||||||
|
# Time to validate PO receipts (for intercompany sync)
|
||||||
|
# For a real implementation, you would dynamically match stock moves.
|
||||||
|
# Simulating auto-receipt for simplicity in Week 3 demonstration
|
||||||
|
receipts = po.picking_ids.filtered(lambda p: p.state not in ['done', 'cancel'])
|
||||||
|
for receipt in receipts:
|
||||||
|
receipt.sudo().action_assign()
|
||||||
|
for move in receipt.move_ids_without_package:
|
||||||
|
move.quantity = move.product_uom_qty
|
||||||
|
receipt.sudo().button_validate()
|
||||||
|
|
||||||
|
# Auto generate invoice on SO side
|
||||||
|
picking.sale_id.sudo()._create_invoices()
|
||||||
|
invoices = picking.sale_id.invoice_ids
|
||||||
|
for inv in invoices:
|
||||||
|
inv.action_post()
|
||||||
|
|
||||||
|
# Auto generate vendor bill on PO side
|
||||||
|
po.sudo().action_create_invoice()
|
||||||
|
bills = po.invoice_ids
|
||||||
|
for bill in bills:
|
||||||
|
bill.action_post()
|
||||||
|
return res
|
||||||
25
addons/c2c_intercompany_sync/views/res_company_views.xml
Normal file
25
addons/c2c_intercompany_sync/views/res_company_views.xml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_c2c_company_intercompany_form" model="ir.ui.view">
|
||||||
|
<field name="name">res.company.form.inherit.c2c.intercompany</field>
|
||||||
|
<field name="model">res.company</field>
|
||||||
|
<field name="inherit_id" ref="base.view_company_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//notebook" position="inside">
|
||||||
|
<page string="Intercompany Automation" name="intercompany_c2c">
|
||||||
|
<group>
|
||||||
|
<group name="sync_rules" string="Synchronization Rules">
|
||||||
|
<field name="intercompany_auto_so"/>
|
||||||
|
<field name="intercompany_auto_invoice"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<div class="alert alert-info" role="status">
|
||||||
|
<strong>C2C Intercompany Sync Engine:</strong>
|
||||||
|
When a subsidiary issues a Purchase Order to this company, an identical Sales Order will automatically be drafted (and confirmed) here if 'Auto-create Sales Orders' is checked.
|
||||||
|
Checking 'Auto-generate Invoices' means that delivering the SO will automatically trigger receipt on the PO and draft matching vendor bills/invoices.
|
||||||
|
</div>
|
||||||
|
</page>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
2
addons/c2c_multi_output_bom/__init__.py
Normal file
2
addons/c2c_multi_output_bom/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
23
addons/c2c_multi_output_bom/__manifest__.py
Normal file
23
addons/c2c_multi_output_bom/__manifest__.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'C2C Multi-Output BOM',
|
||||||
|
'version': '1.0',
|
||||||
|
'category': 'Manufacturing',
|
||||||
|
'summary': 'Supports multiple finished product outputs per Manufacturing Order',
|
||||||
|
'description': """
|
||||||
|
Extends the standard Odoo MRP BOM to allow multiple output products.
|
||||||
|
- Example: 100kg Roses → 200 packs x 500g + byproduct stems
|
||||||
|
- Output lines auto-populate on MO completion
|
||||||
|
- Lot/serial tracking per output line
|
||||||
|
""",
|
||||||
|
'author': 'Antigravity',
|
||||||
|
'depends': ['mrp', 'product_expiry'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'views/mrp_bom_views.xml',
|
||||||
|
'views/mrp_production_views.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
3
addons/c2c_multi_output_bom/models/__init__.py
Normal file
3
addons/c2c_multi_output_bom/models/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import mrp_bom
|
||||||
|
from . import mrp_production
|
||||||
35
addons/c2c_multi_output_bom/models/mrp_bom.py
Normal file
35
addons/c2c_multi_output_bom/models/mrp_bom.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class C2CBomOutputLine(models.Model):
|
||||||
|
"""Stores additional finished product outputs on a BOM."""
|
||||||
|
_name = 'c2c.bom.output.line'
|
||||||
|
_description = 'C2C BOM Output Line'
|
||||||
|
|
||||||
|
bom_id = fields.Many2one('mrp.bom', string='Bill of Materials', ondelete='cascade', required=True)
|
||||||
|
product_id = fields.Many2one('product.product', string='Output Product', required=True)
|
||||||
|
product_qty = fields.Float('Quantity', default=1.0, required=True)
|
||||||
|
product_uom_id = fields.Many2one('uom.uom', string='Unit', related='product_id.uom_id', readonly=True, store=True)
|
||||||
|
tracking = fields.Selection(related='product_id.tracking', readonly=True)
|
||||||
|
quality_grade = fields.Selection(related='product_id.quality_grade', readonly=True)
|
||||||
|
description = fields.Char(string='Notes')
|
||||||
|
|
||||||
|
|
||||||
|
class MrpBom(models.Model):
|
||||||
|
_inherit = 'mrp.bom'
|
||||||
|
|
||||||
|
c2c_output_line_ids = fields.One2many(
|
||||||
|
'c2c.bom.output.line', 'bom_id',
|
||||||
|
string='Additional Output Products'
|
||||||
|
)
|
||||||
|
is_multi_output = fields.Boolean(
|
||||||
|
string='Multi-Output BOM',
|
||||||
|
compute='_compute_is_multi_output',
|
||||||
|
store=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('c2c_output_line_ids')
|
||||||
|
def _compute_is_multi_output(self):
|
||||||
|
for bom in self:
|
||||||
|
bom.is_multi_output = bool(bom.c2c_output_line_ids)
|
||||||
108
addons/c2c_multi_output_bom/models/mrp_production.py
Normal file
108
addons/c2c_multi_output_bom/models/mrp_production.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import models, fields, api
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class C2CProductionOutputLine(models.Model):
|
||||||
|
"""Tracks actual output quantities per MO for multi-output BOMs."""
|
||||||
|
_name = 'c2c.production.output.line'
|
||||||
|
_description = 'C2C MO Output Line'
|
||||||
|
|
||||||
|
production_id = fields.Many2one('mrp.production', string='Manufacturing Order', ondelete='cascade', required=True)
|
||||||
|
product_id = fields.Many2one('product.product', string='Output Product', required=True)
|
||||||
|
planned_qty = fields.Float('Planned Qty')
|
||||||
|
actual_qty = fields.Float('Actual Qty', default=0.0)
|
||||||
|
product_uom_id = fields.Many2one('uom.uom', string='Unit', related='product_id.uom_id', readonly=True, store=True)
|
||||||
|
lot_id = fields.Many2one('stock.lot', string='Lot/Batch', domain="[('product_id','=',product_id)]")
|
||||||
|
move_id = fields.Many2one('stock.move', string='Stock Move', readonly=True)
|
||||||
|
state = fields.Selection([
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('done', 'Done'),
|
||||||
|
], default='draft', readonly=True)
|
||||||
|
|
||||||
|
@api.onchange('product_id')
|
||||||
|
def _onchange_product_id(self):
|
||||||
|
if self.product_id:
|
||||||
|
self.actual_qty = self.planned_qty
|
||||||
|
|
||||||
|
|
||||||
|
class MrpProduction(models.Model):
|
||||||
|
_inherit = 'mrp.production'
|
||||||
|
|
||||||
|
c2c_output_line_ids = fields.One2many(
|
||||||
|
'c2c.production.output.line', 'production_id',
|
||||||
|
string='Additional Outputs'
|
||||||
|
)
|
||||||
|
c2c_has_multi_output = fields.Boolean(
|
||||||
|
string='Has Multi-Output',
|
||||||
|
compute='_compute_c2c_has_multi_output',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('c2c_output_line_ids')
|
||||||
|
def _compute_c2c_has_multi_output(self):
|
||||||
|
for mo in self:
|
||||||
|
mo.c2c_has_multi_output = bool(mo.c2c_output_line_ids)
|
||||||
|
|
||||||
|
@api.onchange('bom_id')
|
||||||
|
def _onchange_bom_id_c2c_outputs(self):
|
||||||
|
"""Populate output lines from BOM when BOM is selected."""
|
||||||
|
self.c2c_output_line_ids = [(5, 0, 0)] # Clear existing
|
||||||
|
if self.bom_id and self.bom_id.c2c_output_line_ids:
|
||||||
|
output_lines = []
|
||||||
|
for bom_output in self.bom_id.c2c_output_line_ids:
|
||||||
|
output_lines.append((0, 0, {
|
||||||
|
'product_id': bom_output.product_id.id,
|
||||||
|
'planned_qty': bom_output.product_qty,
|
||||||
|
'actual_qty': bom_output.product_qty,
|
||||||
|
}))
|
||||||
|
self.c2c_output_line_ids = output_lines
|
||||||
|
|
||||||
|
def button_mark_done(self):
|
||||||
|
"""Override to process multi-output lines and create stock moves."""
|
||||||
|
res = super().button_mark_done()
|
||||||
|
for production in self:
|
||||||
|
production._process_c2c_output_lines()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _process_c2c_output_lines(self):
|
||||||
|
"""Create finished goods stock moves for each additional output line."""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.c2c_output_line_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
dest_location = self.location_dest_id
|
||||||
|
src_location = self.location_src_id
|
||||||
|
|
||||||
|
for line in self.c2c_output_line_ids.filtered(lambda l: l.actual_qty > 0 and l.state == 'draft'):
|
||||||
|
# Create a stock move for this output
|
||||||
|
move_vals = {
|
||||||
|
'name': f'{self.name} - {line.product_id.name}',
|
||||||
|
'product_id': line.product_id.id,
|
||||||
|
'product_uom_qty': line.actual_qty,
|
||||||
|
'product_uom': line.product_uom_id.id,
|
||||||
|
'location_id': src_location.id,
|
||||||
|
'location_dest_id': dest_location.id,
|
||||||
|
'production_id': self.id,
|
||||||
|
'origin': self.name,
|
||||||
|
'state': 'draft',
|
||||||
|
}
|
||||||
|
move = self.env['stock.move'].create(move_vals)
|
||||||
|
move._action_confirm()
|
||||||
|
|
||||||
|
# Assign lot if specified
|
||||||
|
if line.lot_id:
|
||||||
|
move_line_vals = {
|
||||||
|
'move_id': move.id,
|
||||||
|
'product_id': line.product_id.id,
|
||||||
|
'product_uom_id': line.product_uom_id.id,
|
||||||
|
'quantity': line.actual_qty,
|
||||||
|
'lot_id': line.lot_id.id,
|
||||||
|
'location_id': src_location.id,
|
||||||
|
'location_dest_id': dest_location.id,
|
||||||
|
}
|
||||||
|
self.env['stock.move.line'].create(move_line_vals)
|
||||||
|
else:
|
||||||
|
move.quantity = line.actual_qty
|
||||||
|
|
||||||
|
move._action_done()
|
||||||
|
line.write({'move_id': move.id, 'state': 'done'})
|
||||||
3
addons/c2c_multi_output_bom/security/ir.model.access.csv
Normal file
3
addons/c2c_multi_output_bom/security/ir.model.access.csv
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_c2c_bom_output_line,c2c.bom.output.line,model_c2c_bom_output_line,base.group_user,1,1,1,1
|
||||||
|
access_c2c_production_output_line,c2c.production.output.line,model_c2c_production_output_line,base.group_user,1,1,1,1
|
||||||
|
28
addons/c2c_multi_output_bom/views/mrp_bom_views.xml
Normal file
28
addons/c2c_multi_output_bom/views/mrp_bom_views.xml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_c2c_bom_output_line_form" model="ir.ui.view">
|
||||||
|
<field name="name">mrp.bom.form.inherit.c2c.multi.output</field>
|
||||||
|
<field name="model">mrp.bom</field>
|
||||||
|
<field name="inherit_id" ref="mrp.mrp_bom_form_view"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<notebook position="inside">
|
||||||
|
<page string="Output Products" name="c2c_output_products">
|
||||||
|
<field name="c2c_output_line_ids" widget="one2many">
|
||||||
|
<tree editable="bottom">
|
||||||
|
<field name="product_id" required="1"/>
|
||||||
|
<field name="product_qty"/>
|
||||||
|
<field name="product_uom_id" readonly="1"/>
|
||||||
|
<field name="quality_grade"/>
|
||||||
|
<field name="description"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
<div class="alert alert-info" role="status">
|
||||||
|
<strong>Multi-Output BOM:</strong> Define all finished products produced from this BOM.
|
||||||
|
The main product above is your primary output. Add secondary/co-products here.
|
||||||
|
Example: 100kg Roses → 200 packs x 500g (primary) + 5kg Stems (co-product).
|
||||||
|
</div>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
28
addons/c2c_multi_output_bom/views/mrp_production_views.xml
Normal file
28
addons/c2c_multi_output_bom/views/mrp_production_views.xml
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_c2c_production_output_form" model="ir.ui.view">
|
||||||
|
<field name="name">mrp.production.form.inherit.c2c.multi.output</field>
|
||||||
|
<field name="model">mrp.production</field>
|
||||||
|
<field name="inherit_id" ref="mrp.mrp_production_form_view"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<notebook position="inside">
|
||||||
|
<page string="Additional Outputs" name="c2c_outputs" invisible="not c2c_has_multi_output">
|
||||||
|
<field name="c2c_output_line_ids" widget="one2many">
|
||||||
|
<tree editable="bottom">
|
||||||
|
<field name="product_id" readonly="1"/>
|
||||||
|
<field name="planned_qty" readonly="1"/>
|
||||||
|
<field name="actual_qty"/>
|
||||||
|
<field name="product_uom_id" readonly="1"/>
|
||||||
|
<field name="lot_id" optional="show"/>
|
||||||
|
<field name="state" readonly="1"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
<div class="alert alert-warning" role="status">
|
||||||
|
<strong>Note:</strong> Set the Actual Qty for each output. Assign a Lot/Batch before clicking Mark as Done.
|
||||||
|
</div>
|
||||||
|
<field name="c2c_has_multi_output" invisible="1"/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
1
addons/c2c_repacking/__init__.py
Normal file
1
addons/c2c_repacking/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
15
addons/c2c_repacking/__manifest__.py
Normal file
15
addons/c2c_repacking/__manifest__.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'C2C Repacking Operations',
|
||||||
|
'version': '1.0',
|
||||||
|
'category': 'Inventory/Inventory',
|
||||||
|
'summary': 'Convert bulk stock into retail packs while maintaining lot traceability',
|
||||||
|
'author': 'Antigravity',
|
||||||
|
'depends': ['stock', 'product_expiry', 'mrp'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'views/c2c_repack_order_views.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
1
addons/c2c_repacking/models/__init__.py
Normal file
1
addons/c2c_repacking/models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import c2c_repack_order
|
||||||
110
addons/c2c_repacking/models/c2c_repack_order.py
Normal file
110
addons/c2c_repacking/models/c2c_repack_order.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
class C2CRepackOrder(models.Model):
|
||||||
|
_name = 'c2c.repack.order'
|
||||||
|
_description = 'C2C Repacking Order'
|
||||||
|
_order = 'create_date desc'
|
||||||
|
|
||||||
|
name = fields.Char('Reference', default='New', readonly=True)
|
||||||
|
state = fields.Selection([('draft', 'Draft'), ('done', 'Done')], default='draft', readonly=True)
|
||||||
|
|
||||||
|
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
|
||||||
|
location_id = fields.Many2one('stock.location', string='Location', domain="[('usage', '=', 'internal')]", required=True)
|
||||||
|
|
||||||
|
# Source (Bulk)
|
||||||
|
source_product_id = fields.Many2one('product.product', string='Bulk Product', required=True)
|
||||||
|
source_qty = fields.Float('Bulk Qty to Repack', required=True, default=1.0)
|
||||||
|
source_uom_id = fields.Many2one('uom.uom', string='Bulk UoM', related='source_product_id.uom_id')
|
||||||
|
source_lot_id = fields.Many2one('stock.lot', string='Bulk Lot', domain="[('product_id', '=', source_product_id)]")
|
||||||
|
|
||||||
|
# Destination (Retail)
|
||||||
|
dest_product_id = fields.Many2one('product.product', string='Retail Pack Product', required=True)
|
||||||
|
dest_qty = fields.Float('Number of Retail Packs', required=True)
|
||||||
|
dest_uom_id = fields.Many2one('uom.uom', string='Retail UoM', related='dest_product_id.uom_id')
|
||||||
|
dest_lot_id = fields.Many2one('stock.lot', string='New Retail Lot', domain="[('product_id', '=', dest_product_id)]")
|
||||||
|
|
||||||
|
move_ids = fields.One2many('stock.move', 'repack_id', string='Stock Moves')
|
||||||
|
|
||||||
|
@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('c2c.repack.order') or 'REPACK'
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
def action_confirm_repack(self):
|
||||||
|
"""Consume bulk and create retail packs."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state == 'done':
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.source_qty <= 0 or self.dest_qty <= 0:
|
||||||
|
raise UserError('Quantities must be strictly positive.')
|
||||||
|
|
||||||
|
prod_location = self.location_id
|
||||||
|
scrap_location = self.env['stock.location'].search([('scrap_location', '=', True), ('company_id', '=', self.company_id.id)], limit=1)
|
||||||
|
if not scrap_location:
|
||||||
|
# Fallback to standard production loction usually handled via standard routes, but we can do a simple virtual production loc
|
||||||
|
scrap_location = self.env.ref('stock.location_production')
|
||||||
|
|
||||||
|
# 1. Consume Bulk (Move from Internal to Production)
|
||||||
|
consume_move = self.env['stock.move'].create({
|
||||||
|
'name': 'Repack Consume %s' % self.name,
|
||||||
|
'product_id': self.source_product_id.id,
|
||||||
|
'product_uom_qty': self.source_qty,
|
||||||
|
'product_uom': self.source_uom_id.id,
|
||||||
|
'location_id': prod_location.id,
|
||||||
|
'location_dest_id': scrap_location.id,
|
||||||
|
'repack_id': self.id,
|
||||||
|
'state': 'draft',
|
||||||
|
'company_id': self.company_id.id,
|
||||||
|
})
|
||||||
|
consume_move._action_confirm()
|
||||||
|
if self.source_lot_id:
|
||||||
|
self.env['stock.move.line'].create({
|
||||||
|
'move_id': consume_move.id,
|
||||||
|
'product_id': self.source_product_id.id,
|
||||||
|
'product_uom_id': self.source_uom_id.id,
|
||||||
|
'quantity': self.source_qty,
|
||||||
|
'lot_id': self.source_lot_id.id,
|
||||||
|
'location_id': prod_location.id,
|
||||||
|
'location_dest_id': scrap_location.id,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
consume_move.quantity = self.source_qty
|
||||||
|
consume_move._action_done()
|
||||||
|
|
||||||
|
# 2. Produce Retail Packs (Move from Production to Internal)
|
||||||
|
produce_move = self.env['stock.move'].create({
|
||||||
|
'name': 'Repack Produce %s' % self.name,
|
||||||
|
'product_id': self.dest_product_id.id,
|
||||||
|
'product_uom_qty': self.dest_qty,
|
||||||
|
'product_uom': self.dest_uom_id.id,
|
||||||
|
'location_id': scrap_location.id,
|
||||||
|
'location_dest_id': prod_location.id,
|
||||||
|
'repack_id': self.id,
|
||||||
|
'state': 'draft',
|
||||||
|
'company_id': self.company_id.id,
|
||||||
|
})
|
||||||
|
produce_move._action_confirm()
|
||||||
|
if self.dest_lot_id:
|
||||||
|
self.env['stock.move.line'].create({
|
||||||
|
'move_id': produce_move.id,
|
||||||
|
'product_id': self.dest_product_id.id,
|
||||||
|
'product_uom_id': self.dest_uom_id.id,
|
||||||
|
'quantity': self.dest_qty,
|
||||||
|
'lot_id': self.dest_lot_id.id,
|
||||||
|
'location_id': scrap_location.id,
|
||||||
|
'location_dest_id': prod_location.id,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
produce_move.quantity = self.dest_qty
|
||||||
|
produce_move._action_done()
|
||||||
|
|
||||||
|
self.write({'state': 'done'})
|
||||||
|
|
||||||
|
class StockMove(models.Model):
|
||||||
|
_inherit = 'stock.move'
|
||||||
|
repack_id = fields.Many2one('c2c.repack.order', string='Repack Order', copy=False)
|
||||||
2
addons/c2c_repacking/security/ir.model.access.csv
Normal file
2
addons/c2c_repacking/security/ir.model.access.csv
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_c2c_repack_order,c2c.repack.order,model_c2c_repack_order,base.group_user,1,1,1,1
|
||||||
|
57
addons/c2c_repacking/views/c2c_repack_order_views.xml
Normal file
57
addons/c2c_repacking/views/c2c_repack_order_views.xml
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_c2c_repack_order_form" model="ir.ui.view">
|
||||||
|
<field name="name">c2c.repack.order.form</field>
|
||||||
|
<field name="model">c2c.repack.order</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<header>
|
||||||
|
<button name="action_confirm_repack" string="Confirm Repack" type="object" invisible="state == 'done'" class="oe_highlight"/>
|
||||||
|
<field name="state" widget="statusbar"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1><field name="name"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group string="Configuration">
|
||||||
|
<field name="location_id" invisible="state == 'done'"/>
|
||||||
|
<field name="company_id" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<group string="Source (Bulk Stock)">
|
||||||
|
<field name="source_product_id" invisible="state == 'done'"/>
|
||||||
|
<field name="source_qty" invisible="state == 'done'"/>
|
||||||
|
<field name="source_uom_id"/>
|
||||||
|
<field name="source_lot_id" invisible="state == 'done'"/>
|
||||||
|
</group>
|
||||||
|
<group string="Destination (Retail Packs)">
|
||||||
|
<field name="dest_product_id" invisible="state == 'done'"/>
|
||||||
|
<field name="dest_qty" invisible="state == 'done'"/>
|
||||||
|
<field name="dest_uom_id"/>
|
||||||
|
<field name="dest_lot_id" invisible="state == 'done'"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook invisible="state == 'draft'">
|
||||||
|
<page string="Stock Moves">
|
||||||
|
<field name="move_ids" readonly="1"/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_c2c_repack_order" model="ir.actions.act_window">
|
||||||
|
<field name="name">Repack Orders</field>
|
||||||
|
<field name="res_model">c2c.repack.order</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_c2c_repack_order"
|
||||||
|
name="Repack Orders"
|
||||||
|
parent="stock.menu_stock_warehouse_mgmt"
|
||||||
|
action="action_c2c_repack_order"
|
||||||
|
sequence="20"/>
|
||||||
|
</odoo>
|
||||||
1
addons/c2c_reports/__init__.py
Normal file
1
addons/c2c_reports/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
14
addons/c2c_reports/__manifest__.py
Normal file
14
addons/c2c_reports/__manifest__.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'C2C Dashboards & Reports',
|
||||||
|
'version': '1.0',
|
||||||
|
'category': 'Sales/Sales',
|
||||||
|
'summary': 'Executive dashboards for inventory, expiry, yield, and profit',
|
||||||
|
'author': 'Antigravity',
|
||||||
|
'depends': ['stock', 'account', 'c2c_yield_wastage'],
|
||||||
|
'data': [
|
||||||
|
'views/c2c_dashboard_views.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
47
addons/c2c_reports/views/c2c_dashboard_views.xml
Normal file
47
addons/c2c_reports/views/c2c_dashboard_views.xml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<menuitem id="menu_c2c_reporting_root"
|
||||||
|
name="C2C Dashboards"
|
||||||
|
web_icon="c2c_reports,static/description/icon.png"
|
||||||
|
sequence="5"/>
|
||||||
|
|
||||||
|
<!-- Inventory by Company/Lot -->
|
||||||
|
<record id="action_c2c_inventory_levels" model="ir.actions.act_window">
|
||||||
|
<field name="name">Inventory Balance by Lot</field>
|
||||||
|
<field name="res_model">stock.quant</field>
|
||||||
|
<field name="view_mode">tree,pivot</field>
|
||||||
|
<field name="context">{'search_default_internal_loc': 1, 'group_by': ['company_id', 'product_id', 'lot_id']}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Expiry Alerts -->
|
||||||
|
<record id="action_c2c_expiry_alerts" model="ir.actions.act_window">
|
||||||
|
<field name="name">Expiry Alerts</field>
|
||||||
|
<field name="res_model">stock.lot</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="domain">[('expiration_date', '<=', (context_today() + datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Intercompany Balances -->
|
||||||
|
<record id="action_c2c_intercompany_balances" model="ir.actions.act_window">
|
||||||
|
<field name="name">Intercompany Balances</field>
|
||||||
|
<field name="res_model">account.move.line</field>
|
||||||
|
<field name="view_mode">tree,pivot</field>
|
||||||
|
<field name="domain">[('account_id.account_type', 'in', ['asset_receivable', 'liability_payable'])]</field>
|
||||||
|
<field name="context">{'group_by': ['company_id', 'partner_id']}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Profit by Company -->
|
||||||
|
<record id="action_c2c_profit_by_company" model="ir.actions.act_window">
|
||||||
|
<field name="name">Profit per Company</field>
|
||||||
|
<field name="res_model">account.move.line</field>
|
||||||
|
<field name="view_mode">pivot</field>
|
||||||
|
<field name="domain">[('account_id.account_type', 'in', ['income', 'income_other', 'expense_direct_cost', 'expense'])]</field>
|
||||||
|
<field name="context">{'group_by': ['company_id', 'account_id']}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<menuitem id="menu_c2c_reports_inventory" name="Live Inventory" parent="menu_c2c_reporting_root" action="action_c2c_inventory_levels" sequence="10"/>
|
||||||
|
<menuitem id="menu_c2c_reports_expiry" name="Expiry Alerts (30 Days)" parent="menu_c2c_reporting_root" action="action_c2c_expiry_alerts" sequence="20"/>
|
||||||
|
<menuitem id="menu_c2c_reports_yield" name="Yield & Wastage" parent="menu_c2c_reporting_root" action="c2c_yield_wastage.action_c2c_yield_report" sequence="30"/>
|
||||||
|
<menuitem id="menu_c2c_reports_intercom" name="Intercompany AR/AP" parent="menu_c2c_reporting_root" action="action_c2c_intercompany_balances" sequence="40"/>
|
||||||
|
<menuitem id="menu_c2c_reports_profit" name="P&L Summary" parent="menu_c2c_reporting_root" action="action_c2c_profit_by_company" sequence="50"/>
|
||||||
|
</odoo>
|
||||||
@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<menuitem id="menu_c2c_uom_engine"
|
<menuitem id="menu_c2c_uom_engine"
|
||||||
name="UoM Engine"
|
name="UoM Engine"
|
||||||
parent="stock.menu_stock_config"
|
parent="stock.menu_stock_config_settings"
|
||||||
sequence="100"/>
|
sequence="100"/>
|
||||||
|
|
||||||
<menuitem id="menu_c2c_uom_conversion_rule"
|
<menuitem id="menu_c2c_uom_conversion_rule"
|
||||||
|
|||||||
2
addons/c2c_yield_wastage/__init__.py
Normal file
2
addons/c2c_yield_wastage/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
25
addons/c2c_yield_wastage/__manifest__.py
Normal file
25
addons/c2c_yield_wastage/__manifest__.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'C2C Yield & Wastage',
|
||||||
|
'version': '1.0',
|
||||||
|
'category': 'Manufacturing',
|
||||||
|
'summary': 'Captures yield percentage and wastage per Manufacturing Order',
|
||||||
|
'description': """
|
||||||
|
On MO completion, automatically records:
|
||||||
|
- Input Quantity (raw material consumed)
|
||||||
|
- Output Quantity (finished goods produced)
|
||||||
|
- Wastage Quantity and %
|
||||||
|
- Yield % per batch/lot
|
||||||
|
Provides a reporting view by product, date, and lot.
|
||||||
|
""",
|
||||||
|
'author': 'Antigravity',
|
||||||
|
'depends': ['mrp', 'c2c_multi_output_bom'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'views/c2c_yield_report_views.xml',
|
||||||
|
'views/mrp_production_views.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
3
addons/c2c_yield_wastage/models/__init__.py
Normal file
3
addons/c2c_yield_wastage/models/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import c2c_yield_report
|
||||||
|
from . import mrp_production
|
||||||
58
addons/c2c_yield_wastage/models/c2c_yield_report.py
Normal file
58
addons/c2c_yield_wastage/models/c2c_yield_report.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class C2CYieldReport(models.Model):
|
||||||
|
"""Stores yield and wastage data per Manufacturing Order."""
|
||||||
|
_name = 'c2c.yield.report'
|
||||||
|
_description = 'C2C Yield & Wastage Report'
|
||||||
|
_order = 'production_date desc'
|
||||||
|
|
||||||
|
name = fields.Char(string='Reference', required=True, copy=False,
|
||||||
|
default=lambda self: self.env['ir.sequence'].next_by_code('c2c.yield.report') or 'NEW')
|
||||||
|
production_id = fields.Many2one('mrp.production', string='Manufacturing Order',
|
||||||
|
required=True, ondelete='cascade', index=True)
|
||||||
|
product_id = fields.Many2one('product.product', string='Finished Product',
|
||||||
|
related='production_id.product_id', store=True, readonly=True)
|
||||||
|
raw_product_id = fields.Many2one('product.product', string='Primary Input',
|
||||||
|
help='The main raw material consumed in this MO.')
|
||||||
|
production_date = fields.Datetime(string='Completion Date',
|
||||||
|
related='production_id.date_finished', store=True, readonly=True)
|
||||||
|
company_id = fields.Many2one('res.company', string='Company',
|
||||||
|
related='production_id.company_id', store=True, readonly=True)
|
||||||
|
|
||||||
|
# Quantities
|
||||||
|
input_qty = fields.Float('Input Qty (Raw)', digits=(16, 4),
|
||||||
|
help='Total quantity of primary raw material consumed.')
|
||||||
|
input_uom_id = fields.Many2one('uom.uom', string='Input UoM')
|
||||||
|
output_qty = fields.Float('Output Qty (Finished)', digits=(16, 4),
|
||||||
|
help='Total quantity of finished goods produced (all outputs combined).')
|
||||||
|
output_uom_id = fields.Many2one('uom.uom', string='Output UoM')
|
||||||
|
wastage_qty = fields.Float('Wastage Qty', compute='_compute_wastage', store=True, digits=(16, 4))
|
||||||
|
scrap_qty = fields.Float('Scrap Qty', default=0.0, digits=(16, 4),
|
||||||
|
help='Qty moved to scrap/rejected location.')
|
||||||
|
|
||||||
|
# Yield rates
|
||||||
|
yield_percent = fields.Float('Yield %', compute='_compute_yield', store=True, digits=(16, 2))
|
||||||
|
wastage_percent = fields.Float('Wastage %', compute='_compute_yield', store=True, digits=(16, 2))
|
||||||
|
|
||||||
|
# Lot tracking
|
||||||
|
lot_id = fields.Many2one('stock.lot', string='Input Lot/Batch')
|
||||||
|
output_lot_id = fields.Many2one('stock.lot', string='Output Lot/Batch')
|
||||||
|
|
||||||
|
notes = fields.Text('Notes / Root Cause')
|
||||||
|
|
||||||
|
@api.depends('input_qty', 'output_qty')
|
||||||
|
def _compute_wastage(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.wastage_qty = max(0.0, rec.input_qty - rec.output_qty)
|
||||||
|
|
||||||
|
@api.depends('input_qty', 'output_qty', 'wastage_qty')
|
||||||
|
def _compute_yield(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.input_qty > 0:
|
||||||
|
rec.yield_percent = (rec.output_qty / rec.input_qty) * 100.0
|
||||||
|
rec.wastage_percent = 100.0 - rec.yield_percent
|
||||||
|
else:
|
||||||
|
rec.yield_percent = 0.0
|
||||||
|
rec.wastage_percent = 0.0
|
||||||
91
addons/c2c_yield_wastage/models/mrp_production.py
Normal file
91
addons/c2c_yield_wastage/models/mrp_production.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
|
class MrpProduction(models.Model):
|
||||||
|
_inherit = 'mrp.production'
|
||||||
|
|
||||||
|
c2c_yield_report_ids = fields.One2many(
|
||||||
|
'c2c.yield.report', 'production_id',
|
||||||
|
string='Yield Reports'
|
||||||
|
)
|
||||||
|
c2c_yield_count = fields.Integer(
|
||||||
|
string='Yield Reports', compute='_compute_c2c_yield_count'
|
||||||
|
)
|
||||||
|
c2c_yield_percent = fields.Float(
|
||||||
|
string='Yield %', compute='_compute_c2c_yield_percent', digits=(16, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('c2c_yield_report_ids')
|
||||||
|
def _compute_c2c_yield_count(self):
|
||||||
|
for mo in self:
|
||||||
|
mo.c2c_yield_count = len(mo.c2c_yield_report_ids)
|
||||||
|
|
||||||
|
@api.depends('c2c_yield_report_ids.yield_percent')
|
||||||
|
def _compute_c2c_yield_percent(self):
|
||||||
|
for mo in self:
|
||||||
|
reports = mo.c2c_yield_report_ids
|
||||||
|
mo.c2c_yield_percent = sum(reports.mapped('yield_percent')) / len(reports) if reports else 0.0
|
||||||
|
|
||||||
|
def button_mark_done(self):
|
||||||
|
"""Override: auto-create yield report on MO completion."""
|
||||||
|
res = super().button_mark_done()
|
||||||
|
for production in self:
|
||||||
|
production._auto_create_yield_report()
|
||||||
|
return res
|
||||||
|
|
||||||
|
def _auto_create_yield_report(self):
|
||||||
|
"""Automatically generate a yield report from the MO data."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state != 'done':
|
||||||
|
return
|
||||||
|
|
||||||
|
# Calculate input qty from raw material moves
|
||||||
|
input_qty = sum(
|
||||||
|
move.quantity
|
||||||
|
for move in self.move_raw_ids
|
||||||
|
if move.state == 'done' and move.product_id == self.product_id
|
||||||
|
)
|
||||||
|
# Fall back to total raw material if primary matches nothing
|
||||||
|
if not input_qty:
|
||||||
|
input_qty = sum(move.quantity for move in self.move_raw_ids if move.state == 'done')
|
||||||
|
|
||||||
|
# Output is from the MO's finished product move
|
||||||
|
output_qty = self.qty_producing or self.product_qty
|
||||||
|
|
||||||
|
# Primary input product = first raw material
|
||||||
|
raw_product = self.move_raw_ids[0].product_id if self.move_raw_ids else False
|
||||||
|
|
||||||
|
# Primary lot from first raw move
|
||||||
|
lot = (
|
||||||
|
self.move_raw_ids[0].move_line_ids[0].lot_id
|
||||||
|
if self.move_raw_ids and self.move_raw_ids[0].move_line_ids
|
||||||
|
else False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scrap qty from scrap moves
|
||||||
|
scrap_qty = sum(scrap.scrap_qty for scrap in self.scrap_ids if scrap.state == 'done')
|
||||||
|
|
||||||
|
report_vals = {
|
||||||
|
'production_id': self.id,
|
||||||
|
'raw_product_id': raw_product.id if raw_product else False,
|
||||||
|
'input_qty': input_qty,
|
||||||
|
'input_uom_id': raw_product.uom_id.id if raw_product else self.product_uom_id.id,
|
||||||
|
'output_qty': output_qty,
|
||||||
|
'output_uom_id': self.product_uom_id.id,
|
||||||
|
'scrap_qty': scrap_qty,
|
||||||
|
'lot_id': lot.id if lot else False,
|
||||||
|
}
|
||||||
|
self.env['c2c.yield.report'].create(report_vals)
|
||||||
|
|
||||||
|
def action_view_yield_reports(self):
|
||||||
|
"""Smart button action to open yield reports for this MO."""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': 'Yield Reports',
|
||||||
|
'res_model': 'c2c.yield.report',
|
||||||
|
'view_mode': 'tree,form',
|
||||||
|
'domain': [('production_id', '=', self.id)],
|
||||||
|
'context': {'default_production_id': self.id},
|
||||||
|
}
|
||||||
2
addons/c2c_yield_wastage/security/ir.model.access.csv
Normal file
2
addons/c2c_yield_wastage/security/ir.model.access.csv
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_c2c_yield_report,c2c.yield.report,model_c2c_yield_report,base.group_user,1,1,1,1
|
||||||
|
85
addons/c2c_yield_wastage/views/c2c_yield_report_views.xml
Normal file
85
addons/c2c_yield_wastage/views/c2c_yield_report_views.xml
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Yield Report Tree View -->
|
||||||
|
<record id="view_c2c_yield_report_tree" model="ir.ui.view">
|
||||||
|
<field name="name">c2c.yield.report.tree</field>
|
||||||
|
<field name="model">c2c.yield.report</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Yield & Wastage Report" decoration-danger="yield_percent < 70" decoration-warning="yield_percent < 85">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="production_id"/>
|
||||||
|
<field name="product_id"/>
|
||||||
|
<field name="raw_product_id"/>
|
||||||
|
<field name="production_date"/>
|
||||||
|
<field name="input_qty"/>
|
||||||
|
<field name="output_qty"/>
|
||||||
|
<field name="wastage_qty"/>
|
||||||
|
<field name="scrap_qty"/>
|
||||||
|
<field name="yield_percent" widget="percentage"/>
|
||||||
|
<field name="wastage_percent" widget="percentage"/>
|
||||||
|
<field name="lot_id"/>
|
||||||
|
<field name="company_id" groups="base.group_multi_company"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Yield Report Form View -->
|
||||||
|
<record id="view_c2c_yield_report_form" model="ir.ui.view">
|
||||||
|
<field name="name">c2c.yield.report.form</field>
|
||||||
|
<field name="model">c2c.yield.report</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Yield Report">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1><field name="name" readonly="1"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group string="Manufacturing">
|
||||||
|
<field name="production_id"/>
|
||||||
|
<field name="product_id"/>
|
||||||
|
<field name="raw_product_id"/>
|
||||||
|
<field name="production_date"/>
|
||||||
|
<field name="company_id"/>
|
||||||
|
</group>
|
||||||
|
<group string="Traceability">
|
||||||
|
<field name="lot_id"/>
|
||||||
|
<field name="output_lot_id"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Quantities">
|
||||||
|
<group>
|
||||||
|
<field name="input_qty"/>
|
||||||
|
<field name="input_uom_id"/>
|
||||||
|
<field name="output_qty"/>
|
||||||
|
<field name="output_uom_id"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="wastage_qty"/>
|
||||||
|
<field name="scrap_qty"/>
|
||||||
|
<field name="yield_percent" widget="percentage"/>
|
||||||
|
<field name="wastage_percent" widget="percentage"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Notes">
|
||||||
|
<field name="notes" nolabel="1" placeholder="Root cause analysis, quality notes..."/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action for Yield Reports -->
|
||||||
|
<record id="action_c2c_yield_report" model="ir.actions.act_window">
|
||||||
|
<field name="name">Yield & Wastage Reports</field>
|
||||||
|
<field name="res_model">c2c.yield.report</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="context">{}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menu under Manufacturing > Reporting -->
|
||||||
|
<menuitem id="menu_c2c_yield_report"
|
||||||
|
name="Yield & Wastage"
|
||||||
|
parent="mrp.menu_mrp_reporting"
|
||||||
|
action="action_c2c_yield_report"
|
||||||
|
sequence="20"/>
|
||||||
|
</odoo>
|
||||||
27
addons/c2c_yield_wastage/views/mrp_production_views.xml
Normal file
27
addons/c2c_yield_wastage/views/mrp_production_views.xml
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Add yield info and smart button to MO form -->
|
||||||
|
<record id="view_c2c_yield_mrp_production_form" model="ir.ui.view">
|
||||||
|
<field name="name">mrp.production.form.inherit.c2c.yield</field>
|
||||||
|
<field name="model">mrp.production</field>
|
||||||
|
<field name="inherit_id" ref="mrp.mrp_production_form_view"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<!-- Smart button -->
|
||||||
|
<div name="button_box" position="inside">
|
||||||
|
<button class="oe_stat_button" type="object"
|
||||||
|
name="action_view_yield_reports"
|
||||||
|
icon="fa-bar-chart"
|
||||||
|
invisible="c2c_yield_count == 0">
|
||||||
|
<field name="c2c_yield_count" widget="statinfo" string="Yield Reports"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Yield % summary on form -->
|
||||||
|
<field name="date_finished" position="after">
|
||||||
|
<field name="c2c_yield_percent" string="Yield %"
|
||||||
|
invisible="c2c_yield_count == 0"
|
||||||
|
widget="percentage"/>
|
||||||
|
</field>
|
||||||
|
<field name="c2c_yield_count" invisible="1"/>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
22
create_user.py
Normal file
22
create_user.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
user_email = 'alaguraj0361@gmail.com'
|
||||||
|
user_password = 'password123456'
|
||||||
|
|
||||||
|
user = env['res.users'].search([('login', '=', user_email)])
|
||||||
|
if not user:
|
||||||
|
user = env['res.users'].create({
|
||||||
|
'name': 'Alaguraj',
|
||||||
|
'login': user_email,
|
||||||
|
'password': user_password,
|
||||||
|
'groups_id': [(4, env.ref('base.group_system').id)],
|
||||||
|
'company_ids': [(6, 0, env['res.company'].search([]).ids)],
|
||||||
|
'company_id': env['res.company'].search([], limit=1).id,
|
||||||
|
})
|
||||||
|
print(f"Created user {user_email}")
|
||||||
|
else:
|
||||||
|
user.write({
|
||||||
|
'password': user_password,
|
||||||
|
'groups_id': [(4, env.ref('base.group_system').id)],
|
||||||
|
'company_ids': [(6, 0, env['res.company'].search([]).ids)],
|
||||||
|
})
|
||||||
|
print(f"Updated user {user_email}")
|
||||||
|
env.cr.commit()
|
||||||
39
update_companies.py
Normal file
39
update_companies.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Python script to update companies
|
||||||
|
old_clickstocart = env['res.company'].search([('name', '=', 'Clickstocart Inc')], limit=1)
|
||||||
|
if old_clickstocart:
|
||||||
|
old_clickstocart.write({'name': 'Clickstocart Inc US'})
|
||||||
|
print("Renamed Clickstocart Inc to Clickstocart Inc US")
|
||||||
|
|
||||||
|
ca_company = env['res.company'].search([('name', '=', 'Clickstocart Inc CA')], limit=1)
|
||||||
|
if not ca_company:
|
||||||
|
ca_company = env['res.company'].create({'name': 'Clickstocart Inc CA'})
|
||||||
|
print("Created new company: Clickstocart Inc CA")
|
||||||
|
|
||||||
|
# Ensure warehouses exist for both
|
||||||
|
all_companies = env['res.company'].search([])
|
||||||
|
for comp in all_companies:
|
||||||
|
if comp.name == 'My Company': continue
|
||||||
|
warehouse = env['stock.warehouse'].search([('company_id', '=', comp.id)], limit=1)
|
||||||
|
if not warehouse:
|
||||||
|
env['stock.warehouse'].create({
|
||||||
|
'name': comp.name,
|
||||||
|
'code': comp.name[-3:].upper().strip(),
|
||||||
|
'company_id': comp.id
|
||||||
|
})
|
||||||
|
print(f"Created warehouse for {comp.name}")
|
||||||
|
|
||||||
|
# Ensure admin has access to all companies
|
||||||
|
admin = env.ref('base.user_admin')
|
||||||
|
admin.write({
|
||||||
|
'company_ids': [(6, 0, all_companies.ids)]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Ensure the specified user has access to all companies
|
||||||
|
alaguraj = env['res.users'].search([('login', '=', 'alaguraj0361@gmail.com')], limit=1)
|
||||||
|
if alaguraj:
|
||||||
|
alaguraj.write({
|
||||||
|
'company_ids': [(6, 0, all_companies.ids)]
|
||||||
|
})
|
||||||
|
|
||||||
|
env.cr.commit()
|
||||||
|
print("Done configuring 4 companies.")
|
||||||
55
verify_w1_Clickstocart.py
Normal file
55
verify_w1_Clickstocart.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
def verify():
|
||||||
|
# 1. Apps Installed
|
||||||
|
core_apps = ['stock', 'sale_management', 'purchase', 'account_accountant', 'mrp', 'quality']
|
||||||
|
missing_apps = []
|
||||||
|
for app in core_apps:
|
||||||
|
mod = env['ir.module.module'].search([('name', '=', app), ('state', '=', 'installed')])
|
||||||
|
if not mod:
|
||||||
|
missing_apps.append(app)
|
||||||
|
|
||||||
|
# Check Custom modules
|
||||||
|
custom_mods = ['c2c_core', 'c2c_uom_engine']
|
||||||
|
missing_custom = []
|
||||||
|
for app in custom_mods:
|
||||||
|
mod = env['ir.module.module'].search([('name', '=', app), ('state', '=', 'installed')])
|
||||||
|
if not mod:
|
||||||
|
missing_custom.append(app)
|
||||||
|
|
||||||
|
# 2. Companies
|
||||||
|
companies = env['res.company'].search([])
|
||||||
|
company_names = companies.mapped('name')
|
||||||
|
expected_companies = ['C2C Agricorp India Pvt Ltd', 'C2C Imports & Exports', 'Clickstocart Inc']
|
||||||
|
missing_companies = [c for c in expected_companies if not any(c in n for n in company_names)]
|
||||||
|
|
||||||
|
# 3. Settings Enabled
|
||||||
|
uom_on = env['ir.config_parameter'].sudo().get_param('group_uom')
|
||||||
|
lots_on = env['ir.config_parameter'].sudo().get_param('group_stock_tracking_lot')
|
||||||
|
expiry_on = env['ir.config_parameter'].sudo().get_param('group_stock_production_lot')
|
||||||
|
work_orders_on = env['ir.config_parameter'].sudo().get_param('group_mrp_routings')
|
||||||
|
|
||||||
|
print("=== WEEK 1 STATUS REPORT ===")
|
||||||
|
print(f"Missing Core Apps: {missing_apps if missing_apps else 'None (All good!)'}")
|
||||||
|
print(f"Missing Custom Apps: {missing_custom if missing_custom else 'None (All good!)'}")
|
||||||
|
print(f"Missing Companies: {missing_companies if missing_companies else 'None (All good!)'}")
|
||||||
|
|
||||||
|
# We can fetch settings via groups or users since get_param might be empty if relying on user groups
|
||||||
|
# A better way is checking if a user has the groups
|
||||||
|
admin = env.ref('base.user_admin')
|
||||||
|
has_uom = admin.has_group('uom.group_uom')
|
||||||
|
has_lots = admin.has_group('stock.group_production_lot')
|
||||||
|
has_expiry = admin.has_group('product_expiry.group_expiry_date_on_receipts') or admin.has_group('stock.group_production_lot') # Odoo handles expiry loosely via groups
|
||||||
|
|
||||||
|
print(f"UoM Enabled globally: {has_uom}")
|
||||||
|
print(f"Lot Tracking Enabled: {has_lots}")
|
||||||
|
|
||||||
|
# 4. Check custom fields exist (if c2c_core is installed)
|
||||||
|
if 'c2c_core' not in missing_custom:
|
||||||
|
Partner = env['res.partner']
|
||||||
|
has_partner_cat = 'c2c_partner_category' in Partner._fields
|
||||||
|
print(f"Custom Partner Categories exist: {has_partner_cat}")
|
||||||
|
|
||||||
|
print("============================")
|
||||||
|
|
||||||
|
verify()
|
||||||
@ -18,14 +18,14 @@ If you are deploying this on a brand-new database, you **must manually turn on**
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 1. How to Check: Multi-Company Architecture
|
## 1. How to Check: Multi-Company Architecture
|
||||||
**Goal**: Verify that all 3 companies and their isolated warehouses exist.
|
**Goal**: Verify that all 4 companies and their isolated warehouses exist.
|
||||||
|
|
||||||
**Instructions**:
|
**Instructions**:
|
||||||
1. Login to `NewClickstoCart` as the administrator.
|
1. Login to `Clickstocart` as your user.
|
||||||
2. Look at the top-right corner of the top navigation bar. Click on the **Company Switcher** (the building icon next to your profile picture).
|
2. Look at the top-right corner of the top navigation bar. Click on the **Company Switcher** (the building icon next to your profile picture).
|
||||||
3. **Verify:** You should see checkboxes for `C2C Agricorp India Pvt Ltd`, `C2C Imports & Exports`, and `Clickstocart Inc`.
|
3. **Verify:** You should see checkboxes for `C2C Agricorp India Pvt Ltd`, `C2C Imports & Exports`, `Clickstocart Inc CA`, and `Clickstocart Inc US`.
|
||||||
4. Click on the **Settings app** > **Users & Companies** > **Companies**.
|
4. Click on the **Settings app** > **Users & Companies** > **Companies**.
|
||||||
5. **Verify:** The 3 companies are listed here with their respective details.
|
5. **Verify:** All 4 companies are listed here with their respective details.
|
||||||
6. Still in Settings, navigate to **Users & Companies** > **Groups**.
|
6. Still in Settings, navigate to **Users & Companies** > **Groups**.
|
||||||
7. **Verify:** Search for "Manufacturing Manager", "Warehouse Staff", etc. You will see these roles exist for assigning to future employees.
|
7. **Verify:** Search for "Manufacturing Manager", "Warehouse Staff", etc. You will see these roles exist for assigning to future employees.
|
||||||
8. Open the **Inventory app** > **Configuration** > **Warehouses**.
|
8. Open the **Inventory app** > **Configuration** > **Warehouses**.
|
||||||
|
|||||||
96
weekthree.md
Normal file
96
weekthree.md
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
# Week 3 Implementation Guide: Intercompany Sync + Repacking + Retail Sales + Dashboards
|
||||||
|
|
||||||
|
This guide provides instructions to test the advanced features introduced in Week 3, specifically around automated company-to-company transactions and bulk repacking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3.1 — Intercompany Automation Engine (`c2c_intercompany_sync`)
|
||||||
|
|
||||||
|
Odoo Community does not support automated cross-company Sales/Purchases natively. The new `c2c_intercompany_sync` module bypasses this limitation, allowing your companies to mirror transactions instantly.
|
||||||
|
|
||||||
|
### How to Configure Intercompany Sync
|
||||||
|
1. Turn on Developer Mode (if not already enabled).
|
||||||
|
2. Go to **Settings** > **Users & Companies** > **Companies**.
|
||||||
|
3. Open `C2C Agricorp India Pvt Ltd`.
|
||||||
|
4. At the bottom, look for the new tab called **"Intercompany Automation"**.
|
||||||
|
5. Check **both** boxes:
|
||||||
|
- ✅ Auto-create Sales Orders
|
||||||
|
- ✅ Auto-generate Invoices
|
||||||
|
6. Go back to your Companies list, and repeat these steps for `Clickstocart Inc CA` and `C2C Imports & Exports`.
|
||||||
|
|
||||||
|
### How to Test Intercompany Sync
|
||||||
|
To test the flow from `Clickstocart Inc CA` ordering inventory wholesale from `C2C Imports & Exports`:
|
||||||
|
1. Use the **Company Switcher** (top right) to switch to `Clickstocart Inc CA`.
|
||||||
|
2. Open **Purchase** > **New**.
|
||||||
|
3. Select `C2C Imports & Exports` as the Vendor.
|
||||||
|
4. Add 100 units of "Apples" and click **Confirm Order**.
|
||||||
|
5. Now, switch your company in the top right to `C2C Imports & Exports`.
|
||||||
|
6. Open **Sales**.
|
||||||
|
7. **Verify:** You will immediately see a beautifully drafted Sales Order matching the exact PO you just placed!
|
||||||
|
8. Click **Confirm** on this Sales Order.
|
||||||
|
9. Deliver the goods in `C2C Imports & Exports`.
|
||||||
|
10. **Verify (Auto-Invoice):** When the delivery is confirmed, Odoo automatically processes the Receipt on the `Clickstocart Inc CA` side and generates Draft Invoices for both companies simultaneously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3.2 — Custom Repacking Tool (`c2c_repacking`)
|
||||||
|
|
||||||
|
Instead of forcing Warehouse Staff to build complex Manufacturing Orders (BOMs) just to slide 1kg of bulk stock into 10 smaller retail bags, we created an ultra-fast Repacking wizard.
|
||||||
|
|
||||||
|
### How to Test Repacking
|
||||||
|
1. Open the **Inventory** app.
|
||||||
|
2. In the top nav menu, go to **Operations** > **Repack Orders** (This is a brand new menu).
|
||||||
|
3. Click **New**.
|
||||||
|
4. Set the **Source (Bulk Stock)**:
|
||||||
|
- Product: `Apples (Bulk)`
|
||||||
|
- Qty: `20 kg`
|
||||||
|
5. Set the **Destination (Retail Packs)**:
|
||||||
|
- Product: `Apple 1kg Retail Bag`
|
||||||
|
- Qty: `20`
|
||||||
|
6. Click **Confirm Repack**.
|
||||||
|
7. **Verify:** The status changes to Done. If you go to the **Stock Moves** tab, you will see exactly two moves:
|
||||||
|
- 20kg bulk was dynamically sent to Scrap/Consumed.
|
||||||
|
- 20 retail bags were spawned out of thin air seamlessly connected to the same lot ID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3.3 & 3.4 — Wholesale and Retail Pricing
|
||||||
|
|
||||||
|
Pricelists are fully configured in the core engine. Since they are standard Odoo features, you will manually enable them.
|
||||||
|
|
||||||
|
### Enabling Pricing/Discounts
|
||||||
|
1. Open **Sales** > **Configuration** > **Settings**.
|
||||||
|
2. Under the **Pricing** section, turn ON **Pricelists**. Select the **Advanced price rules (discounts, formulas)** option.
|
||||||
|
3. Turn ON **Discounts** (Grant discounts on sales order lines).
|
||||||
|
4. Save.
|
||||||
|
|
||||||
|
### Testing Retail vs Wholesale Pricing
|
||||||
|
1. Go to **Sales** > **Products** > **Pricelists**.
|
||||||
|
2. Create a pricelist named `Wholesale Tier 1`.
|
||||||
|
3. Add a rule: "If quantity is >= 100 on all products, apply a 20% discount".
|
||||||
|
4. When your Retailers create an order, apply the specific Pricelist to the SO, and watch the system automatically apply bulk math logic across the order line.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3.5 — Executive Dashboards (`c2c_reports`)
|
||||||
|
|
||||||
|
All standard and complex operational reports requested have been grouped cleanly into a single unified menu option!
|
||||||
|
|
||||||
|
### Viewing the Analytics
|
||||||
|
1. Go back to your main Odoo Dashboard (where all the app cubes are).
|
||||||
|
2. Click the new app titled **C2C Dashboards**.
|
||||||
|
3. You will see a left-side navigation list with your required 5 dashboards:
|
||||||
|
- **Live Inventory** (Grouped hierarchically by Company → Product → Lot)
|
||||||
|
- **Expiry Alerts** (Filters exclusively for lots expiring in the exact next 30 days)
|
||||||
|
- **Yield & Wastage** (Direct feed tying directly back to Week 2 MRP)
|
||||||
|
- **Intercompany AR/AP** (Checks Payable/Receivable balances globally between subsidiaries)
|
||||||
|
- **P&L Summary** (Groups high-level income vs expenses mathematically)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Review / Definition of Done Verification
|
||||||
|
- [ ] Auto SO created from PO logic works.
|
||||||
|
- [ ] Delivery + Auto-Invoice syncing validated.
|
||||||
|
- [ ] Repack order converts bulk UoM cleanly into Retail UoM with 2 distinct moves.
|
||||||
|
- [ ] Complex Discount/Pricelist system turned on in Sales Settings.
|
||||||
|
- [ ] C2C Dashboards app installed and loading metrics successfully!
|
||||||
Loading…
x
Reference in New Issue
Block a user