from odoo import models, fields, api, _
from odoo.exceptions import UserError
from datetime import date, datetime
import requests
import logging
import pytz
from pytz import timezone
import base64
import xlrd, xlwt
import io
_logger = logging.getLogger(__name__)
class SourcingJobOrder(models.Model):
_name = 'sourcing.job.order'
_description = 'Sourcing Job Order MD'
_rec_name = 'name'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'is_priority desc, state asc, create_date desc'
name = fields.Char(string='Job Number', default='New', copy=False, readonly=True)
leads_id = fields.Many2one('crm.lead', string='Leads Number')
md_user_id = fields.Many2many('res.users', string='MD Persons', compute="_compute_md_persons")
so_id = fields.Many2one('sale.order', string='SO Number', tracking=True)
state = fields.Selection([
('draft', 'Untaken'),
('taken', 'On Sourcing'),
('partial', 'Partial Complete'),
('done', 'Complete'),
('cancel', 'Cancelled')
], string='Status', default='draft', tracking=True)
approval_sales = fields.Selection([
('draft', 'Requested'),
('approve', 'Approved'),
('reject', 'Rejected'),
], string='Approval Sales', tracking=True)
takeover_request = fields.Many2one(
'res.users',
string='Takeover Requested By',
readonly=True,
tracking=True,
help='MD yang meminta takeover'
)
is_priority = fields.Boolean(
string="Priority",
default=False,
tracking=True,
help="Otomatis aktif jika request approval ditolak oleh sales."
)
eta_sales = fields.Date(string='Expected Ready')
eta_complete = fields.Date(string='Completed Date')
cancel_reason = fields.Text(string="Reason for Cancel", tracking=True)
line_ids = fields.One2many('sourcing.job.order.line', 'order_id', string='Products')
line_sales_input_ids = fields.One2many(
'sourcing.job.order.line', 'order_id',
string='Sales Input Lines',
)
line_sales_view_ids = fields.One2many(
'sourcing.job.order.line', 'order_id',
string='Sales View Lines',
)
converted_product_ids = fields.One2many(
"product.product",
"sourcing_job_id",
string="Converted Products",
readonly=True,
)
converted_product_count = fields.Integer(
compute="_compute_converted_product_count",
string="Converted Product Count",
)
progress_status = fields.Char(
string='Progress Status',
compute='_compute_progress_status',
default=''
)
has_price_in_lines = fields.Boolean(
string='Has Line with Price',
compute='_compute_has_price_in_lines',
)
def _get_jakarta_today(self):
jakarta_tz = pytz.timezone('Asia/Jakarta')
now_jakarta = datetime.now(jakarta_tz)
return now_jakarta.date()
@api.depends('eta_sales', 'eta_complete', 'create_date', 'state')
def _compute_progress_status(self):
for rec in self:
if rec.state == 'cancel':
rec.progress_status = 'โซ Cancelled'
continue
if rec.state == 'done':
if rec.eta_sales and rec.eta_complete:
delta = (rec.eta_complete - rec.eta_sales).days
if delta < 0:
rec.progress_status = f'๐ข Early {abs(delta)} hari'
elif delta == 0:
rec.progress_status = '๐ต Ontime'
else:
rec.progress_status = f'๐ด Delay {delta} hari'
elif rec.create_date and rec.eta_complete:
durasi = (rec.eta_complete - rec.create_date.date()).days
rec.progress_status = f'โ
Selesai dalam {durasi} hari'
else:
rec.progress_status = 'โ
Selesai'
continue
if rec.state in ['taken', 'partial', 'draft']:
rec.progress_status = '๐ก On Track'
@api.depends('line_ids.price', 'line_ids.vendor_id')
def _compute_has_price_in_lines(self):
for rec in self:
# Cek apakah ada minimal satu line yang sudah punya price > 0 dan vendor_id
has_price = any(
(line.price and line.price > 0 and line.vendor_id)
for line in rec.line_ids
)
rec.has_price_in_lines = bool(has_price)
@api.depends('line_ids.md_person_ids')
def _compute_md_persons(self):
for rec in self:
md_users = rec.line_ids.mapped('md_person_ids').filtered(lambda x: x)
rec.md_user_id = [(6, 0, md_users.ids)]
@api.depends("converted_product_ids")
def _compute_converted_product_count(self):
for rec in self:
rec.converted_product_count = len(rec.converted_product_ids)
@api.model
def create(self, vals):
"""Hanya Sales & Merchandiser yang boleh membuat job."""
if not (self.env.user.has_group('indoteknik_custom.group_role_sales') or
self.env.user.has_group('indoteknik_custom.group_role_merchandiser')):
raise UserError("โ Hanya Sales dan Merchandiser yang boleh membuat Sourcing Job.")
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code('sourcing.job.order') or 'New'
rec = super().create(vals)
return rec
def write(self, vals):
if self.env.uid != self.create_uid.id and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
raise UserError("โ Hanya Sales dan Merchandiser yang boleh mengedit Sourcing Job.")
old_data = {}
for rec in self:
old_data[rec.id] = {
'state': rec.state,
'approval_sales': rec.approval_sales,
'line_data': {
line.id: {
'state': line.state,
'vendor_id': line.vendor_id.id if line.vendor_id else False,
'price': line.price,
}
for line in rec.line_ids
},
}
res = super().write(vals)
if vals.get('product_assets'):
for rec in self:
rec._log_product_assets_upload()
for rec in self:
changes = []
old = old_data.get(rec.id, {})
if old.get('state') != rec.state:
changes.append(f"State: {old.get('state')} โ {rec.state}")
if old.get('approval_sales') != rec.approval_sales:
changes.append(f"Approval Status: {old.get('approval_sales')} โ {rec.approval_sales}")
old_lines = old.get('line_data', {})
for line in rec.line_ids:
old_line = old_lines.get(line.id)
if not old_line:
continue
if (
old_line['vendor_id'] != (line.vendor_id.id if line.vendor_id else False)
and old_line['price'] == line.price
):
raise UserError(
f"โ ๏ธ Harga untuk produk {line.product_name} belum diperbarui setelah mengganti Vendor."
)
sub_changes = []
if old_line['state'] != line.state:
sub_changes.append(f"- state: {old_line['state']} โ {line.state}")
if old_line['vendor_id'] != (line.vendor_id.id if line.vendor_id else False):
old_vendor = self.env['res.partner'].browse(old_line['vendor_id']).name if old_line['vendor_id'] else '-'
sub_changes.append(f"- vendor: {old_vendor} โ {line.vendor_id.name if line.vendor_id else '-'}")
if old_line['price'] != line.price:
sub_changes.append(f"- price: {old_line['price']} โ {line.price}")
if sub_changes:
joined = "
".join(sub_changes)
changes.append(f"{line.product_name}:
{joined}")
if changes:
message = "
".join(changes)
rec.message_post(
body=f"Perubahan pada Sourcing Job:
{message}",
subtype_xmlid="mail.mt_comment",
)
return res
def action_cancel(self):
for rec in self:
if not self.env.user.has_group('indoteknik_custom.group_role_sales'):
raise UserError("โ Hanya Sales yang dapat mengcancel Sourcing Job.")
if not rec.cancel_reason:
raise UserError("โ ๏ธ Isi alasan pembatalan terlebih dahulu.")
rec.write({'state': 'cancel'})
rec.message_post(body=("Job %s dibatalkan oleh %s
Alasan: %s") %
(rec.name, self.env.user.name, rec.cancel_reason))
def action_open_converted_products(self):
"""Open converted products related to this SJO."""
self.ensure_one()
return {
'name': 'Converted Products',
'type': 'ir.actions.act_window',
'view_mode': 'tree,form',
'res_model': 'product.product',
'domain': [('id', 'in', self.converted_product_ids.ids)],
'context': {'default_sourcing_job_id': self.id},
}
def action_open_export_wizard(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Export Produk ke SO',
'res_model': 'wizard.export.sjo.to.so',
'view_mode': 'form',
'target': 'new',
'context': {
'default_sjo_id': self.id,
}
}
class SourcingJobOrderLine(models.Model):
_name = 'sourcing.job.order.line'
_description = 'Sourcing Job Order Line'
_inherit = ['mail.thread', 'mail.activity.mixin']
order_id = fields.Many2one('sourcing.job.order', string='Job Order', ondelete='cascade')
product_id = fields.Many2one('product.product', string='Product', ondelete='cascade')
md_person_ids = fields.Many2one('res.users', string='MD Person', ondelete='cascade')
brand_id = fields.Many2one('x_manufactures', string='Manufactures', ondelete='cascade')
so_id = fields.Many2one('sale.order', string='SO Number', tracking=True)
product_name_md = fields.Char(string='Nama Barang')
descriptions_md = fields.Text(string='Deskripsi Barang')
product_name = fields.Char(string='Nama Barang')
brand = fields.Char(string='Brand')
code = fields.Char(string='SKU')
budget = fields.Char(string='Expected Price')
note = fields.Text(string='Note Sourcing')
attachment_type = fields.Selection([
('none', 'None'),
('pdf', '.PDF'),
('img', '.IMG'),
('other', 'Lainnya'),
], default='none')
product_attachment_pdf = fields.Binary(string="Product Attachment")
product_attachment_img = fields.Binary(string="Product Attachment")
product_attachment_other = fields.Binary(string="Product Attachment")
product_attachment_filename = fields.Char(string="Filename")
descriptions = fields.Text(string='Deskripsi / Spesifikasi')
reason = fields.Text(string='Reason Unavailable')
sla = fields.Char(string='SLA Product')
quantity = fields.Float(string='Quantity Product', required=True)
price = fields.Float(string='Purchase Price')
now_price = fields.Float(string='Current Purchase Price', readonly=True)
last_updated_price = fields.Datetime(string='Last Update Price', readonly=True)
tax_id = fields.Many2one('account.tax', string='Tax', domain=[('active', '=', True), ('type_tax_use', '=', 'purchase')])
vendor_id = fields.Many2one('res.partner', string="Vendor")
product_category = fields.Many2one('product.category', string="Product Category")
product_class = fields.Many2many('product.public.category', string="Categories")
exported_to_so = fields.Boolean(string="Exported to SO", default=False)
state = fields.Selection([
('draft', 'Unsource'),
('sourcing', 'On Sourcing'),
('sent', 'Approval Sent'),
('approve', 'Done Sourcing'),
('cancel', 'Unavailable')
], default='draft', tracking=True)
product_type = fields.Selection([
('consu', 'Consumable'),
('servis', 'Service'),
('product', 'Storable Product'),
], default='product')
subtotal = fields.Float(string='Subtotal', compute='_compute_subtotal')
show_for_sales = fields.Boolean(
string="Show for Sales",
compute="_compute_show_for_sales",
)
show_salesperson = fields.Many2one(
'res.users',
string="Salesperson",
)
so_state = fields.Selection(
[
('draft', 'Quotation'),
('cancel', 'Cancel'),
('sale', 'Sale Order')
],
string="Status SO",
compute="_compute_so_data"
)
so_name = fields.Char(
string="SO Number",
compute="_compute_so_data"
)
is_md_person = fields.Boolean(
string="Is MD Person",
compute="_compute_is_md_person"
)
is_receiver = fields.Boolean(
string="Is MD Receiver",
compute="_compute_is_md_person"
)
is_given = fields.Boolean(string='Is Given', tracking=True)
given_to_id = fields.Many2one('res.users', string='Given To')
previous_md_id = fields.Many2one('res.users', string='Previous MD')
@api.depends('quantity', 'price', 'tax_id')
def _compute_subtotal(self):
for line in self:
subtotal = (line.quantity or 0.0) * (line.price or 0.0)
if line.tax_id:
tax = line.tax_id.amount / 100
if line.tax_id.price_include:
subtotal = subtotal / (1 + tax)
line.subtotal = subtotal
@api.depends('order_id.so_id.user_id', 'order_id.so_id.state', 'order_id.so_id.name')
def _compute_so_data(self):
for rec in self:
so = rec.order_id.so_id
if so:
rec.so_state = so.state if so.state in ['draft', 'sale'] else False
rec.so_name = so.name
else:
rec.so_state = False
rec.so_name = False
def _compute_is_md_person(self):
current_user = self.env.user
for rec in self:
rec.is_md_person = bool(rec.md_person_ids == current_user)
rec.is_receiver = bool(rec.given_to_id == current_user)
@api.constrains('product_type', 'product_category', 'product_class')
def _check_required_fields_for_md(self):
for rec in self:
if rec.state == 'cancel':
continue
is_md = self.env.user.has_group('indoteknik_custom.group_role_merchandiser')
if is_md and (not rec.product_type or rec.product_category == False or rec.product_class == False):
raise UserError("MD wajib mengisi SKU, Product Type, Product Category, dan Categories!")
@api.depends('price', 'vendor_id', 'order_id')
def _compute_show_for_sales(self):
for rec in self:
rec.show_for_sales = bool(
rec.order_id and rec.price not in (None, 0) and rec.vendor_id
)
@api.model
def create(self, vals):
order_id = vals.get('order_id')
if order_id:
order = self.env['sourcing.job.order'].browse(order_id)
if order.state == 'taken' and order.line_ids.md_person_ids != self.env.user:
raise UserError("โ SJO sudah taken. Tidak boleh tambah line.")
if order.so_id:
vals['so_id'] = order.so_id.id
vals['show_salesperson'] = order.so_id.user_id.id
rec = super().create(vals)
return rec
def write(self, vals):
for rec in self:
if (
rec.md_person_ids
and self.env.uid != rec.md_person_ids.id
and rec.order_id.create_uid != self.env.user
):
raise UserError("โ Hanya MD yang memegang job yang boleh mengedit Sourcing Job.")
res = super().write(vals)
if 'state' in vals:
self._update_parent_state()
return res
def _update_parent_state(self):
for rec in self:
order = rec.order_id
if not order:
continue
lines = order.line_ids
if not lines:
continue
total = len(lines)
if total == 1:
line = lines[0]
if line.state == 'approve':
order.state = 'done'
elif line.state == 'cancel':
order.state = 'cancel'
else:
order.state = 'taken'
continue
states = lines.mapped('state')
all_cancel = all(s == 'cancel' for s in states)
all_done_or_cancel = all(s in ['approve', 'cancel'] for s in states)
any_done = any(s == 'approve' for s in states)
any_progress = any(s not in ['approve', 'cancel', 'draft'] for s in states)
if all_cancel:
order.state = 'cancel'
continue
if all_done_or_cancel:
order.state = 'done'
continue
if any_done and not all_done_or_cancel:
order.state = 'partial'
continue
if any_progress:
order.state = 'taken'
continue
def action_take(self):
for rec in self:
if not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
raise UserError("โ Hanya Merchandiser yang dapat mengambil Sourcing Job.")
if rec.state != 'draft':
continue
rec.state = 'sourcing'
rec.md_person_ids = self.env.uid
rec.order_id.state = 'taken'
line_no = 1
if rec.order_id:
all_lines = self.search(
[('order_id', '=', rec.order_id.id)],
order='id asc'
)
for i, r in enumerate(all_lines, start=1):
if r.id == rec.id:
line_no = i
break
rec.message_post(
body=("Line %s dari Order %s diambil oleh %s")
% (line_no, rec.order_id.name or '-', self.env.user.name)
)
def action_multi_take(self):
if not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
raise UserError("โ Hanya Merchandiser yang dapat mengambil Sourcing Job.")
unsource = self.filtered(lambda r: r.state == 'draft')
if not unsource:
raise UserError("Tidak ada record Unsource untuk diambil.")
unsource.write({
'state': 'sourcing',
'md_person_ids': self.env.uid
})
for rec in unsource:
if rec.order_id.state == 'draft':
rec.order_id.state = 'taken'
line_no = self.search_count([
('order_id', '=', rec.order_id.id),
('id', '<=', rec.id)
])
rec.message_post(
body=("Line %s dari Order %s diambil oleh %s")
% (line_no, rec.order_id.name or '-', self.env.user.name)
)
if rec.order_id.state != 'draft':
continue
def action_multi_ask_approval(self):
bot_sjo = '8335015210:AAGbObP0jQf7ptyqJhYdBYn5Rm0CWOd_yIM'
chat_sjo = '6076436058'
api_base = f'https://api.telegram.org/bot{bot_sjo}/sendMessage'
order_ids = self.mapped('order_id')
if len(order_ids) != 1:
raise UserError("โ Semua line harus berasal dari Sourcing Job yang sama.")
order_ids = self.mapped('order_id')
if len(order_ids) != 1:
raise UserError("โ Semua line harus berasal dari Sourcing Job yang sama.")
job = order_ids[0]
md_users = self.mapped('md_person_ids')
if len(md_users) != 1 or md_users[0] != self.env.user:
raise UserError("โ Hanya MD yang memegang semua line ini yang bisa request approval.")
for line in self:
if line.state != 'sourcing':
raise UserError(f"โ ๏ธ Produk '{line.product_name_md}' bukan status Sourcing.")
if (
not line.vendor_id
or not line.product_name_md
or not brand_id
or not line.price or line.price <= 0
or not line.tax_id
or not line.subtotal or line.subtotal <= 0
or not line.product_type
or not line.product_category
or not line.product_class
):
raise UserError(f"โ Data produk '{line.product_name_md}' belum lengkap.")
activity_type = self.env.ref('mail.mail_activity_data_todo')
approved_lines_text = ""
for line in self:
line.state = 'sent'
line.activity_schedule(
activity_type_id=activity_type.id,
user_id=job.create_uid.id,
note=f"{self.env.user.name} meminta approval untuk produk '{line.product_name_md}' di SJO '{job.name}'.",
)
approved_lines_text += f"