223 lines
12 KiB
Python
223 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
from odoo import models, fields, api, _
|
|
from odoo.exceptions import UserError
|
|
|
|
class SaleOrder(models.Model):
|
|
_inherit = 'sale.order'
|
|
|
|
is_garment_order = fields.Boolean(string='Garment Manufacturing Order', default=True)
|
|
garment_style_id = fields.Many2one('garment.style', string='Garment Style', tracking=True)
|
|
buyer_id = fields.Many2one('res.partner', string='Buyer / Brand', tracking=True)
|
|
buyer_po_ref = fields.Char(string='Buyer PO Reference', tracking=True)
|
|
season_id = fields.Many2one('garment.season', string='Season')
|
|
customer_style_ref = fields.Char(string='Buyer Style Code')
|
|
|
|
# Shipment Details
|
|
ex_factory_date = fields.Date(string='Ex-Factory Date', tracking=True)
|
|
shipment_date = fields.Date(string='Target Shipment Date', tracking=True)
|
|
shipment_mode = fields.Selection([
|
|
('sea', 'Sea Freight (FCL/LCL)'),
|
|
('air', 'Air Freight'),
|
|
('courier', 'Express Courier'),
|
|
('road', 'Road Transport'),
|
|
], string='Shipment Mode', default='sea')
|
|
destination_port = fields.Char(string='Destination Port / City')
|
|
|
|
# Order Matrix
|
|
order_matrix_line_ids = fields.One2many('garment.order.matrix.line', 'order_id', string='Size-Color Order Matrix')
|
|
total_garment_qty = fields.Integer(string='Total Garment Pieces', compute='_compute_total_garment_qty', store=True)
|
|
matrix_html_summary = fields.Html(string='Order Matrix Summary', compute='_compute_matrix_html_summary')
|
|
|
|
# Production Link
|
|
production_plan_ids = fields.One2many('garment.production.plan', 'sale_order_id', string='Production Plans')
|
|
production_plan_count = fields.Integer(string='Production Plans Count', compute='_compute_production_plan_count')
|
|
|
|
@api.onchange('garment_style_id')
|
|
def _onchange_garment_style_id(self):
|
|
if self.garment_style_id:
|
|
self.season_id = self.garment_style_id.season_id
|
|
self.customer_style_ref = self.garment_style_id.customer_style_ref
|
|
if self.garment_style_id.buyer_id and not self.buyer_id:
|
|
self.buyer_id = self.garment_style_id.buyer_id
|
|
|
|
@api.depends('order_matrix_line_ids.quantity')
|
|
def _compute_total_garment_qty(self):
|
|
for order in self:
|
|
order.total_garment_qty = sum(order.order_matrix_line_ids.mapped('quantity'))
|
|
|
|
@api.depends('order_matrix_line_ids.quantity', 'order_matrix_line_ids.color_id', 'order_matrix_line_ids.size_id')
|
|
def _compute_matrix_html_summary(self):
|
|
for order in self:
|
|
lines = order.order_matrix_line_ids
|
|
if not lines:
|
|
order.matrix_html_summary = "<p class='text-muted'>No matrix lines configured yet.</p>"
|
|
continue
|
|
|
|
# Distinct sorted colors and sizes
|
|
colors = sorted(lines.mapped('color_id'), key=lambda c: c.name)
|
|
sizes = sorted(lines.mapped('size_id'), key=lambda s: (s.sequence, s.code))
|
|
|
|
# Map (color_id, size_id) -> quantity
|
|
grid = {}
|
|
for l in lines:
|
|
grid[(l.color_id.id, l.size_id.id)] = grid.get((l.color_id.id, l.size_id.id), 0) + l.quantity
|
|
|
|
# Build HTML table
|
|
html = ["""
|
|
<div class="o_garment_matrix_container w-100 mb-3" style="width: 100% !important; max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch;">
|
|
<table class="table table-sm table-bordered text-center align-middle" style="width: 100% !important; min-width: 650px; margin-bottom: 0; font-size: 13.5px; border: 1px solid #dee2e6; border-collapse: collapse;">
|
|
<thead>
|
|
<tr style="background-color: #1e293b; color: #ffffff;">
|
|
<th class="text-start" style="min-width: 170px; padding: 10px 14px; background-color: #1e293b; color: #ffffff; border: 1px solid #334155; font-weight: 600;">Color \\ Size</th>
|
|
"""]
|
|
for s in sizes:
|
|
html.append(f"<th style='min-width: 80px; padding: 10px 8px; background-color: #1e293b; color: #ffffff; border: 1px solid #334155; font-weight: 600;'>{s.code}</th>")
|
|
html.append("<th style='min-width: 110px; padding: 10px 12px; background-color: #0f172a; color: #38bdf8; border: 1px solid #334155; font-weight: 700;'>Total Pcs</th></tr></thead><tbody>")
|
|
|
|
total_col = {s.id: 0 for s in sizes}
|
|
grand_total = 0
|
|
|
|
for idx, c in enumerate(colors):
|
|
c_hex = c.color_hex or '#eee'
|
|
row_bg = '#ffffff' if idx % 2 == 0 else '#f8fafc'
|
|
html.append(f"<tr style='background-color: {row_bg}; border-bottom: 1px solid #e2e8f0;'>")
|
|
html.append(f"<td class='text-start fw-semibold' style='padding: 8px 14px; white-space: nowrap; color: #1e293b;'><span style='display:inline-block;width:14px;height:14px;background-color:{c_hex};border:1px solid #94a3b8;margin-right:8px;border-radius:3px;vertical-align:middle;box-shadow:0 1px 2px rgba(0,0,0,0.15);'></span>{c.name}</td>")
|
|
row_sum = 0
|
|
for s in sizes:
|
|
qty = grid.get((c.id, s.id), 0)
|
|
row_sum += qty
|
|
total_col[s.id] += qty
|
|
cell_val = f"<span class='fw-semibold text-dark'>{qty:,}</span>" if qty else "<span style='color: #94a3b8;'>-</span>"
|
|
html.append(f"<td style='padding: 8px 6px; border: 1px solid #e2e8f0;'>{cell_val}</td>")
|
|
grand_total += row_sum
|
|
html.append(f"<td class='fw-bold' style='padding: 8px 12px; background-color: #f1f5f9; color: #0f172a; border: 1px solid #cbd5e1;'>{row_sum:,}</td></tr>")
|
|
|
|
# Footer total row
|
|
html.append("<tr style='background-color: #e2e8f0; font-weight: bold; border-top: 2px solid #94a3b8;'>")
|
|
html.append("<td class='text-start' style='padding: 10px 14px; color: #0f172a; font-weight: 700;'>Total Pcs</td>")
|
|
for s in sizes:
|
|
html.append(f"<td style='padding: 10px 6px; color: #0f172a; font-weight: 700; border: 1px solid #cbd5e1;'>{total_col[s.id]:,}</td>")
|
|
html.append(f"<td style='padding: 10px 12px; background-color: #dbeafe; color: #1d4ed8; font-size: 14px; font-weight: 800; border: 1px solid #93c5fd;'>{grand_total:,}</td></tr>")
|
|
html.append("</tbody></table></div>")
|
|
|
|
order.matrix_html_summary = "".join(html)
|
|
|
|
def action_sync_matrix_to_order_lines(self):
|
|
"""Generates or updates standard sale.order.line items from the Garment Matrix"""
|
|
self.ensure_one()
|
|
if not self.garment_style_id:
|
|
raise UserError(_("Please select a Garment Style before synchronizing order lines."))
|
|
if not self.order_matrix_line_ids:
|
|
raise UserError(_("Order Matrix is empty. Please enter size-color quantities first."))
|
|
|
|
# First ensure variants exist
|
|
self.garment_style_id.action_generate_product_variants()
|
|
ProductProduct = self.env['product.product']
|
|
|
|
# Clear existing lines linked to this style or create lines
|
|
created = 0
|
|
for m_line in self.order_matrix_line_ids:
|
|
if m_line.quantity <= 0:
|
|
continue
|
|
sku = f"{self.garment_style_id.name}-{m_line.color_id.code}-{m_line.size_id.code}"
|
|
product = ProductProduct.search([('default_code', '=', sku)], limit=1)
|
|
if not product:
|
|
product = ProductProduct.create({
|
|
'name': f"{self.garment_style_id.style_name} ({m_line.color_id.name}/{m_line.size_id.code})",
|
|
'default_code': sku,
|
|
'type': 'consu',
|
|
})
|
|
|
|
# Find or create sale.order.line
|
|
existing_line = self.order_line.filtered(lambda l: l.product_id.id == product.id)
|
|
if existing_line:
|
|
existing_line.write({
|
|
'product_uom_qty': m_line.quantity,
|
|
'price_unit': m_line.unit_price or existing_line.price_unit or 10.0,
|
|
})
|
|
else:
|
|
uom = self.garment_style_id.uom_id.id if self.garment_style_id.uom_id else self.env.ref('uom.product_uom_unit').id
|
|
sol_vals = {
|
|
'order_id': self.id,
|
|
'product_id': product.id,
|
|
'name': f"[{sku}] {self.garment_style_id.style_name} - {m_line.color_id.name} / {m_line.size_id.name}",
|
|
'product_uom_qty': m_line.quantity,
|
|
'price_unit': m_line.unit_price or 10.0,
|
|
}
|
|
if 'product_uom_id' in self.env['sale.order.line']._fields:
|
|
sol_vals['product_uom_id'] = uom
|
|
elif 'product_uom' in self.env['sale.order.line']._fields:
|
|
sol_vals['product_uom'] = uom
|
|
self.env['sale.order.line'].create(sol_vals)
|
|
created += 1
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _('Sale Lines Synchronized'),
|
|
'message': _('Successfully synchronized %d product variant lines.') % created,
|
|
'sticky': False,
|
|
'type': 'success',
|
|
}
|
|
}
|
|
|
|
def action_create_production_plan(self):
|
|
"""Creates a Garment Production Plan from this Sales Order"""
|
|
self.ensure_one()
|
|
if not self.garment_style_id:
|
|
raise UserError(_("Please assign a Garment Style."))
|
|
if self.total_garment_qty <= 0:
|
|
raise UserError(_("Total order quantity must be greater than 0."))
|
|
|
|
plan = self.env['garment.production.plan'].create({
|
|
'sale_order_id': self.id,
|
|
'style_id': self.garment_style_id.id,
|
|
'planned_qty': self.total_garment_qty,
|
|
'date_start': fields.Date.today(),
|
|
'date_delivery': self.shipment_date or self.ex_factory_date or fields.Date.today(),
|
|
'company_id': self.company_id.id,
|
|
})
|
|
return {
|
|
'name': _('Garment Production Plan'),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'garment.production.plan',
|
|
'res_id': plan.id,
|
|
'view_mode': 'form',
|
|
'target': 'current',
|
|
}
|
|
|
|
@api.depends('production_plan_ids')
|
|
def _compute_production_plan_count(self):
|
|
for order in self:
|
|
order.production_plan_count = len(order.production_plan_ids)
|
|
|
|
def action_view_production_plans(self):
|
|
self.ensure_one()
|
|
return {
|
|
'name': _('Production Plans for SO %s') % self.name,
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'garment.production.plan',
|
|
'view_mode': 'list,form',
|
|
'domain': [('sale_order_id', '=', self.id)],
|
|
'context': {'default_sale_order_id': self.id, 'default_style_id': self.garment_style_id.id},
|
|
}
|
|
|
|
|
|
class GarmentOrderMatrixLine(models.Model):
|
|
_name = 'garment.order.matrix.line'
|
|
_description = 'Garment Order Matrix Line (Color x Size x Quantity)'
|
|
_order = 'color_id, size_id'
|
|
|
|
order_id = fields.Many2one('sale.order', string='Sales Order', required=True, ondelete='cascade')
|
|
color_id = fields.Many2one('garment.color', string='Color', required=True)
|
|
size_id = fields.Many2one('garment.size', string='Size', required=True)
|
|
quantity = fields.Integer(string='Quantity (Pcs)', required=True, default=0)
|
|
unit_price = fields.Float(string='Unit Price', default=0.0)
|
|
subtotal = fields.Float(string='Subtotal', compute='_compute_subtotal', store=True)
|
|
|
|
@api.depends('quantity', 'unit_price')
|
|
def _compute_subtotal(self):
|
|
for rec in self:
|
|
rec.subtotal = rec.quantity * rec.unit_price
|