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')
user_id = fields.Many2one('res.users', string='MD Person', tracking=True)
so_id = fields.Many2one('sale.order', string='SO Number', tracking=True, domain="[('state', '=', 'draft')]")
product_assets_filename = fields.Char(string="Nama File PDF")
state = fields.Selection([
('draft', 'Untaken'),
('taken', 'On Sourcing'),
('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."
)
can_request_takeover = fields.Boolean(
compute="_compute_can_request_takeover"
)
can_approve_takeover = fields.Boolean(
compute="_compute_can_approve_takeover"
)
eta_sales = fields.Date(string='Expected Ready')
eta_complete = fields.Date(string='Completed Date')
cancel_reason = fields.Text(string="Reason for Cancel", tracking=True)
product_assets = fields.Binary(string="Product Assets (PDF)")
line_ids = fields.One2many('sourcing.job.order.line', 'order_id', string='Products')
total_amount = fields.Float(string="Total Purchase", compute='_compute_total_amount')
line_sales_input_ids = fields.One2many(
'sourcing.job.order.line', 'order_id',
string='Sales Input Lines',
domain=['|', ('price', '=', 0), ('price', '=', False)]
)
line_md_edit_ids = fields.One2many(
'sourcing.job.order.line', 'order_id',
string='MD Edit Lines'
)
line_sales_view_ids = fields.One2many(
'sourcing.job.order.line', 'order_id',
string='Sales View Lines',
domain=[('state', 'in', ['sourcing', 'done', 'cancel', 'convert'])]
)
line_sales_view_cancel_ids = fields.One2many(
'sourcing.job.order.line', 'order_id',
string='Sales View Lines',
domain=[('state', '=', 'cancel')]
)
exported_line_ids = fields.One2many(
"sourcing.job.order.line",
"order_id",
string="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",
)
has_price_in_lines = fields.Boolean(
string='Has Line with Price',
compute='_compute_has_price_in_lines',
)
progress_status = fields.Char(
string='Progress Status',
compute='_compute_progress_status',
default=''
)
is_creator_same_user = fields.Boolean(compute='_compute_is_creator_same_user')
can_convert_to_product = fields.Boolean(string="Can Convert", compute="_compute_can_convert_to_product")
def _get_jakarta_today(self):
jakarta_tz = pytz.timezone('Asia/Jakarta')
now_jakarta = datetime.now(jakarta_tz)
return now_jakarta.date()
def action_open_download_template(self):
wizard = self.env['sourcing.job.order.line.template.wizard'].create({})
return {
'type': 'ir.actions.act_window',
'res_model': 'sourcing.job.order.line.template.wizard',
'view_mode': 'form',
'res_id': wizard.id, # โ
ini WAJIB supaya file bisa didownload
'target': 'new',
}
@api.depends('eta_sales', 'eta_complete', 'create_date', 'state')
def _compute_progress_status(self):
for rec in self:
if rec.eta_sales:
# Ada tanggal expected
if rec.state == 'taken':
rec.progress_status = '๐ก On Track'
elif rec.state == 'done' 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.state == 'cancel':
rec.progress_status = 'โซ Cancelled'
else:
rec.progress_status = '๐ก On Track'
else:
# Tidak ada ETA, hitung durasi
if rec.state == 'done' and rec.eta_complete:
if rec.create_date:
durasi = (rec.eta_complete - rec.create_date.date()).days
rec.progress_status = f'โ
Selesai dalam {durasi} hari'
else:
rec.progress_status = 'โ
Selesai'
elif rec.state == 'cancel':
rec.progress_status = 'โซ Cancelled'
else:
rec.progress_status = '๐ก On Track'
@api.depends('line_ids.subtotal')
def _compute_total_amount(self):
for rec in self:
rec.total_amount = sum(line.subtotal for line in rec.line_ids)
@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('user_id', 'takeover_request')
def _compute_can_request_takeover(self):
for rec in self:
current_user = self.env.user
rec.can_request_takeover = (
rec.user_id and rec.user_id != current_user
)
@api.depends('user_id', 'takeover_request')
def _compute_can_approve_takeover(self):
for rec in self:
current_user = self.env.user
rec.can_approve_takeover = (
rec.user_id == current_user and bool(rec.takeover_request)
)
@api.depends('create_uid', 'user_id')
def _compute_is_creator_same_user(self):
for rec in self:
current_user = self.env.user
rec.is_creator_same_user = (
rec.create_uid == current_user and
rec.user_id == current_user
)
@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.onchange('approval_sales')
def _onchange_approval_sales_priority(self):
"""Otomatis tandai priority jika approval_sales = reject"""
for rec in self:
if rec.approval_sales == 'reject':
rec.is_priority = True
else:
rec.is_priority = False
@api.depends('line_md_edit_ids.state')
def _compute_can_convert_to_product(self):
"""Cek apakah ada line dengan state 'done'."""
for rec in self:
rec.can_convert_to_product = any(line.state == 'done' for line in rec.line_md_edit_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'
if self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
vals['user_id'] = self.env.user.id
vals['state'] = 'taken'
rec = super().create(vals)
if vals.get('product_assets'):
rec._log_product_assets_upload()
if rec.create_uid.id == rec.user_id.id and rec.line_md_edit_ids:
rec.line_md_edit_ids.write({'state': 'sourcing'})
return rec
def write(self, vals):
bypass_actions = (
self.env.context.get('from_action_take', False)
or self.env.context.get('from_multi_action_take', False)
or self.env.context.get('from_action_takeover', False)
or self.env.user.has_group('indoteknik_custom.group_role_it')
)
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 mengedit Sourcing Job.")
for rec in self:
if (
not rec.user_id
and rec.create_uid != self.env.user
and not vals.get('user_id')
and not bypass_actions
):
raise UserError("โ SJO ini belum memiliki MD Person. Tidak dapat melakukan edit.")
if (
rec.user_id != self.env.user
and rec.create_uid != self.env.user
and not bypass_actions
):
raise UserError("โ Hanya MD Person dan Creator SJO ini yang bisa melakukan Edit.")
# --- Simpan data lama sebelum write (buat pembanding)
old_data = {}
for rec in self:
old_data[rec.id] = {
'state': rec.state,
'user_id': rec.user_id.id if rec.user_id else False,
'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_md_edit_ids
},
}
if rec.create_uid.id == rec.user_id.id and rec.line_md_edit_ids:
for line in rec.line_md_edit_ids:
if line.state == 'draft':
line.write({'state': 'sourcing'})
elif all([line.vendor_id, line.price, line.tax_id]) and line.state in ('draft', 'sourcing'):
line.write({'state': 'done'})
res = super().write(vals)
if vals.get('product_assets'):
for rec in self:
rec._log_product_assets_upload()
# --- Bandingkan setelah write dan buat log
for rec in self:
changes = []
old = old_data.get(rec.id, {})
# === Perubahan di field parent ===
if old.get('state') != rec.state:
changes.append(f"State: {old.get('state')} โ {rec.state}")
if old.get('user_id') != (rec.user_id.id if rec.user_id else False):
changes.append(f"MD Person: {old.get('user_id')} โ {rec.user_id.name if rec.user_id else '-'}")
if old.get('approval_sales') != rec.approval_sales:
changes.append(f"Approval Status: {old.get('approval_sales')} โ {rec.approval_sales}")
# === Perubahan di line ===
old_lines = old.get('line_data', {})
for line in rec.line_md_edit_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}")
# Post ke chatter
if changes:
message = "
".join(changes)
rec.message_post(
body=f"Perubahan pada Sourcing Job:
{message}",
subtype_xmlid="mail.mt_comment",
)
return res
def action_take(self):
context_action = self.env.context.get('from_action_take', False)
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.with_context(from_action_take=True).write({
'state': 'taken',
'user_id': self.env.user.id
})
if rec.line_md_edit_ids:
rec.line_md_edit_ids.write({'state': 'sourcing'})
rec.message_post(body=("Job %s diambil oleh %s") % (rec.name, self.env.user.name))
def action_multi_take(self):
context_action = self.env.context.get('from_multi_action_take', True)
untaken = self.filtered(lambda r: r.state == 'draft')
if not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
raise UserError("โ Hanya Merchandiser yang bisa mengambil Sourcing Job.")
if not untaken:
raise UserError("Tidak ada record Untaken untuk diambil.")
untaken.write({
'state': 'taken',
'user_id': self.env.user.id,
})
for rec in untaken:
if rec.line_md_edit_ids:
rec.line_md_edit_ids.write({'state': 'sourcing'})
def action_confirm_by_md(self):
for rec in self:
if rec.user_id and rec.user_id != self.env.user:
raise UserError("โ Hanya MD Person yang memiliki SJO ini yang boleh melakukan Confirm.")
invalid_lines = rec.line_md_edit_ids.filtered(lambda l: l.state not in ('cancel', 'convert'))
if invalid_lines:
line_names = ', '.join(invalid_lines.mapped('product_name'))
raise UserError(
f"โ ๏ธ Tidak dapat melakukan Confirm SJO.\n"
f"Masih ada line yang belum selesai disourcing & diconvert: {line_names}"
)
if rec.line_md_edit_ids and all(line.state == 'cancel' for line in rec.line_md_edit_ids):
raise UserError("โ ๏ธ Tidak dapat melakukan Confirm SJO. Semua line pada SJO ini Unavailable.")
rec.approval_sales = 'approve'
rec.state = 'done'
rec.eta_complete = self._get_jakarta_today()
rec.message_post(
body=f"Sourcing Job {rec.name} otomatis disetujui karena pembuat dan MD adalah orang yang sama ({self.env.user.name}).",
subtype_xmlid="mail.mt_comment"
)
self.env.user.notify_success(
message=f"Sourcing Job '{rec.name}' otomatis disetujui dan diselesaikan.",
title="Auto Approved",
)
return {'type': 'ir.actions.client','tag': 'reload',}
def action_confirm_after_approval(self):
for rec in self:
if rec.user_id and rec.user_id != self.env.user:
raise UserError("โ Hanya MD Person yang memiliki SJO ini yang boleh melakukan Confirm.")
done_lines = rec.line_ids.filtered(lambda l: l.state == 'convert')
if not done_lines:
raise UserError("โ ๏ธ Confirm Line hanya bisa dilakukan setelah Convert Line.")
if rec.line_md_edit_ids and all(line.state == 'cancel' for line in rec.line_md_edit_ids):
raise UserError("โ ๏ธ Tidak dapat melakukan Confirm SJO. Semua line pada SJO ini Unavailable.")
rec.state = 'done'
rec.eta_complete = self._get_jakarta_today()
if rec.is_priority == True:
rec.is_priority = False
self.env.user.notify_success(
message=f"Sourcing Job '{rec.name}' Confirmed.",
title="Confirmed",
)
return {'type': 'ir.actions.client','tag': 'reload',}
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_cancel(self):
for rec in self:
if not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
raise UserError("โ Hanya Merchandiser yang dapat mengcancel Sourcing Job.")
if rec.user_id and rec.user_id != self.env.user:
raise UserError("โ Hanya MD Person yang memiliki SJO ini yang boleh melakukan Cancel.")
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_request_takeover(self):
context_action = self.env.context.get('from_action_takeover', True)
for rec in self:
if not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
raise UserError("โ Hanya Merchandiser yang dapat Request Takeover Sourcing Job.")
if rec.takeover_request:
raise UserError(f"SJO ini sudah memiliki request takeover dari {rec.takeover_request.name}. Tunggu approval dulu.")
rec.with_context(from_action_takeover=True).write({'takeover_request': self.env.user.id})
activity_type = self.env.ref('mail.mail_activity_data_todo')
rec.activity_schedule(
activity_type_id=activity_type.id,
user_id=rec.user_id.id,
note=f"{self.env.user.name} meminta approval untuk mengambil alih SJO '{rec.name}'.",
)
rec.message_post(
body=f"{self.env.user.name} mengirimkan request takeover kepada {rec.user_id.name}.",
subtype_xmlid="mail.mt_comment"
)
self.env.user.notify_success(
message=f"Request takeover telah dikirim ke {rec.user_id.name}.",
title="Request Sent",
)
return {'type': 'ir.actions.client','tag': 'reload',}
def action_approve_takeover(self):
for rec in self:
if self.env.user != rec.user_id:
raise UserError("Hanya MD person yang saat ini memegang SJO yang dapat menyetujui takeover ini.")
if not rec.takeover_request:
raise UserError("Tidak ada request takeover yang perlu disetujui.")
new_user = rec.takeover_request
rec.user_id = new_user
rec.takeover_request = False
activities = self.env['mail.activity'].search([
('res_id', '=', rec.id),
('res_model', '=', 'sourcing.job.order'),
('user_id', '=', self.env.user.id)
])
activities.unlink()
rec.message_post(
body=f"Takeover disetujui oleh {self.env.user.name}. Sourcing Job berpindah ke {new_user.name}.",
subtype_xmlid="mail.mt_comment"
)
self.env.user.notify_success(
message=f"Request takeover telah Disetujui dan Dialihkan ke {rec.user_id.name}.",
title="Request Sent",
)
return {'type': 'ir.actions.client','tag': 'reload',}
def action_reject_takeover(self):
for rec in self:
if self.env.user != rec.user_id:
raise UserError("Hanya MD person yang saat ini memegang SJO yang dapat menolak takeover ini.")
if not rec.takeover_request:
raise UserError("Tidak ada request takeover yang perlu ditolak.")
requester = rec.takeover_request
rec.takeover_request = False
activities = self.env['mail.activity'].search([
('res_id', '=', rec.id),
('res_model', '=', 'sourcing.job.order'),
('user_id', '=', self.env.user.id)
])
activities.unlink()
rec.message_post(
body=f"Takeover dari {requester.name} ditolak oleh {self.env.user.name}.",
subtype_xmlid="mail.mt_comment"
)
def action_convert_all_lines(self):
for rec in self:
if rec.user_id != self.env.user:
raise UserError("โ Hanya MD Person dari Sourcing Job ini yang dapat melakukan konversi produk.")
done_lines = rec.line_ids.filtered(lambda l: l.state == 'done')
if rec.create_uid != rec.user_id and rec.approval_sales != 'approve':
raise UserError("โ ๏ธ Convert Line hanya bisa dilakukan setelah sales approve.")
if not done_lines:
raise UserError("โ ๏ธ Tidak ada line dengan status 'Done Sourcing' untuk dikonversi.")
ProductProduct = self.env['product.product']
ProductTemplate = self.env['product.template']
PurchasePricelist = self.env['purchase.pricelist']
existing_skus = []
created_products = []
for line in done_lines:
existing = False
if line.code:
existing = ProductProduct.search([('default_code', '=', line.code)], limit=1)
if existing:
existing_skus.append(line.code)
line.state = 'convert'
continue
type_map = {
'servis': 'service',
'product': 'product',
'consu': 'consu',}
manufactures = self.env['x_manufactures']
if line.brand:
manufactures = manufactures.search([('x_name', 'ilike', line.brand)], limit=1)
new_product = ProductProduct.create({
'name': line.product_name,
'default_code': line.code or False,
'description': line.descriptions or '',
'type': type_map.get(line.product_type, 'product'),
'categ_id': line.product_category.id if line.product_category else False,
'x_manufacture': manufactures.id if manufactures else False,
'standard_price': line.price if line.price else 0,
'public_categ_ids': [(6, 0, [line.product_class.id])] if line.product_class else False,
'active': True,
'sourcing_job_id': rec.id,
})
if not line.code:
sku_auto = 'IT.' + str(new_product.id)
new_product.default_code = sku_auto
line.code = sku_auto
_logger.info(f"SKU otomatis di-set: {sku_auto} untuk produk {new_product.name}")
if new_product:
jakarta_tz = fields.Datetime.now(timezone('Asia/Jakarta')).strftime('%Y-%m-%d %H:%M:%S')
pricelist_vals = {
'product_id': new_product.id,
'vendor_id': line.vendor_id.id,
'system_price': line.price if line.price else 0,
'product_price': line.price if line.price else 0,
'include_price': line.price if line.price else 0,
'taxes_system_id': line.tax_id.id if line.tax_id else False,
'taxes_product_id': line.tax_id.id if line.tax_id else False,
'brand_id': new_product.x_manufacture.id if new_product.x_manufacture else False,
'system_last_update': jakarta_tz,
'human_last_update': jakarta_tz,
'is_winner': True,
}
new_pricelist = PurchasePricelist.create(pricelist_vals)
rec.message_post(
body=(
f"๐งพ Purchase Pricelist berhasil dibuat
"
f"