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"" ), subtype_xmlid="mail.mt_comment" ) _logger.info( f"๐Ÿงพ Purchase Pricelist dibuat untuk produk {new_product.name} " f"dengan vendor {line.vendor_id.name} dan harga {line.price}" ) line.state = 'convert' created_products.append(new_product.name) if created_products: rec.message_post( body=( f"โœ… Berhasil mengonversi {len(created_products)} produk baru:
" + "
".join(created_products) ), subtype_xmlid="mail.mt_comment", ) if existing_skus: rec.message_post( body=( f"โ„น๏ธ SKU berikut sudah ada di sistem dan tidak dibuat ulang:
" + "
".join(existing_skus) ), subtype_xmlid="mail.mt_comment", ) self.env.user.notify_success( message=f"{len(created_products)} produk baru berhasil dikonversi. " f"{len(existing_skus)} SKU sudah ada di sistem.", title="Konversi Selesai", ) return {'type': 'ir.actions.client','tag': 'reload',} def action_ask_approval(self): for rec in self: if rec.user_id != self.env.user: raise UserError("โŒ Hanya MD Person Sourcing Job ini yang dapat Request Approval.") invalid_lines = rec.line_md_edit_ids.filtered(lambda l: l.state not in ('done', 'cancel', 'convert')) if invalid_lines: line_names = ', '.join(invalid_lines.mapped('product_name')) raise UserError( f"โš ๏ธ Tidak dapat melakukan Request Approval.\n" f"Masih ada line yang belum selesai disourcing: {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 Request Approval. Semua line pada SJO ini Unavailable.") bot_sjo = '8335015210:AAGbObP0jQf7ptyqJhYdBYn5Rm0CWOd_yIM' chat_sjo = '6076436058' api_base = f'https://api.telegram.org/bot{bot_sjo}/sendMessage' rec.approval_sales = 'draft' activity_type = self.env.ref('mail.mail_activity_data_todo') rec.activity_schedule( activity_type_id=activity_type.id, user_id=rec.create_uid.id, note=f"{self.env.user.name} meminta approval untuk SJO '{rec.name}'.", ) rec.message_post( body=f"{self.env.user.name} mengirimkan request approval kepada {rec.create_uid.name}.", subtype_xmlid="mail.mt_comment" ) self.env.user.notify_success( message=f"Request Approval telah dikirim ke {rec.create_uid.name}.", title="Request Sent", ) base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') url = f"{base_url}web#id={rec.id}&model=sourcing.job.order&view_type=form" try: msg_text = ( f"๐Ÿ“ข Request Approval Baru\n\n" f"๐Ÿงพ Sourcing Job: ๐Ÿ“Ž {rec.name}\n" f"๐Ÿ‘ค Dari: {self.env.user.name}\n" f"๐Ÿ“… Tanggal: {fields.Datetime.now().strftime('%d-%m-%Y %H:%M')}\n\n" f"Silakan lakukan Review di Odoo." ) payload = { 'chat_id': chat_sjo, 'text': msg_text, 'parse_mode': 'HTML' } response = requests.post(api_base, data=payload) response.raise_for_status() except Exception as e: _logger.warning(f"Gagal kirim pesan Telegram: {e}") return {'type': 'ir.actions.client', 'tag': 'reload'} def action_confirm_approval(self): for rec in self: if rec.create_uid != self.env.user: raise UserError("โŒ Hanya Pembuat Sourcing Job ini yang dapat Confirm Approval.") rec.approval_sales = 'approve' rec.activity_feedback(['mail.mail_activity_data_todo']) rec.message_post( body=f"Sourcing Job disetujui oleh {self.env.user.name}.", subtype_xmlid="mail.mt_comment" ) rec.activity_schedule( 'mail.mail_activity_data_todo', user_id=rec.user_id.id, note=f"โœ… Sourcing Job {rec.name} telah disetujui oleh {self.env.user.name}.", ) self.env.user.notify_info( message=f"Sourcing Job '{rec.name}' telah disetujui dan dikirim ke {rec.user_id.name}.", title="Approval Confirmed", ) return {'type': 'ir.actions.client', 'tag': 'reload'} def action_reject_by_sales(self): for rec in self: if rec.create_uid != self.env.user: raise UserError("โŒ Hanya Sales (pembuat SJO ini) yang dapat melakukan Reject Approval.") return { 'name': 'Reason for Reject', 'type': 'ir.actions.act_window', 'view_mode': 'form', 'res_model': 'sourcing.reject.wizard', 'target': 'new', 'context': { 'default_sjo_id': rec.id, } } def action_send_untaken_to_telegram(self): bot_sjo = '8335015210:AAGbObP0jQf7ptyqJhYdBYn5Rm0CWOd_yIM' chat_group_sjo = '-5081839952' # chat_sjo = '6076436058' api_base = f'https://api.telegram.org/bot{bot_sjo}' data = self.search([('state', '=', 'draft')], order='create_date asc') if not data: text = "โœ… tidak ada Sourcing Job (SJO) yang berstatus Untaken Saat ini" else: text = "โš ๏ธ *Daftar SJO yang masih Untaken:*\n" for sjo in data: text += f"- {sjo.name} | Requested By: {sjo.create_uid.name}\n" payload = { 'chat_id': chat_group_sjo, 'text': text } try: response = requests.post(f"{api_base}/sendMessage", data=payload, timeout=20) if response.status_code == 200: _logger.info(f"โœ… Telegram notification sent successfully at {datetime.now()}") else: _logger.error(f"โŒ Failed to send Telegram message: {response.text}") except Exception as e: _logger.error(f"โš ๏ธ Error while sending Telegram message: {str(e)}") return True def _log_product_assets_upload(self): """Tambahkan log note otomatis saat file PDF diunggah""" if self.product_assets: # Buat attachment dari file biner attachment = self.env['ir.attachment'].create({ 'name': self.product_assets_filename or 'SJO_Assets.pdf', 'type': 'binary', 'datas': self.product_assets, 'res_model': self._name, 'res_id': self.id, 'mimetype': 'application/pdf', }) # Tambahkan log note ke chatter dengan attachment self.message_post( body=_("SJO ini memiliki Dokumen yang diupload."), attachment_ids=[attachment.id], subtype_xmlid='mail.mt_note' ) def action_export_to_so(self): return { "type": "ir.actions.act_window", "name": "Select Products to Export", "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' order_id = fields.Many2one('sourcing.job.order', string='Job Order', ondelete='cascade') product_name = fields.Char(string='Nama Barang', required=True) code = fields.Char(string='SKU') budget = fields.Char(string='Expected Price') note = fields.Text(string='Note Sourcing') brand = fields.Char(string='Brand') product_image = fields.Binary(string="Product Image") 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') tax_id = fields.Many2one('account.tax', string='Tax', domain=[('active', '=', True)]) 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'), ('done', 'Done Sourcing'), ('convert', 'Converted'), ('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", ) selected = fields.Boolean(string="Pilih") @api.depends('quantity', 'price', 'tax_id') def _compute_subtotal(self): """Menghitung subtotal termasuk pajak.""" for line in self: subtotal = (line.quantity or 0.0) * (line.price or 0.0) if line.tax_id: subtotal += subtotal * (line.tax_id.amount / 100) line.subtotal = subtotal @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('state') def _check_unavailable_line(self): for rec in self: if rec.state == 'cancel' and not rec.reason: raise UserError("Isi Reason Unavailable") @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 ) def action_convert_to_product(self): type_map = { 'servis': 'service', 'product': 'product', 'consu': 'consu', } for rec in self: if rec.order_id.user_id != self.env.user: raise UserError("โŒ Hanya MD Person SJO ini yang dapat convert Line ini ke product.") exsisting = self.env['product.product'].search([('default_code', '=', rec.code)], limit=1) if exsisting: raise UserError(f"โš ๏ธ Produk dengan Internal Reference '{rec.code}' sudah ada di sistem.") product = self.env['product.product'].create({ 'name': rec.product_name, 'default_code': rec.code or False, 'description': rec.descriptions or '', 'categ_id': rec.product_category.id, 'type': type_map.get(rec.product_type, 'product'), }) rec.state = 'convert' return True def action_cancel_line(self): for rec in self: if rec.order_id.user_id != self.env.user: raise UserError("โŒ Hanya MD Person SJO ini yang dapat cancel line.") rec.state = 'cancel' @api.onchange('code') def _oncange_code(self): for rec in self: if not rec.code: continue product = self.env['product.product'].search([('default_code', '=', rec.code)], limit=1) if not product: return template = product.product_tmpl_id rec.product_name = product.name or rec.product_name rec.product_name = product.name or rec.product_name rec.product_type = template.type or rec.product_type rec.brand = product.x_manufacture.x_name or rec.brand rec.product_category = template.categ_id.id or rec.product_category rec.product_class = [(6, 0, template.public_categ_ids.ids)] if template.public_categ_ids else [] pricelist = self.env['purchase.pricelist'].search([('product_id', '=', product.id), ('is_winner', '=', True)], limit=1) if pricelist: rec.vendor_id = pricelist.vendor_id.id or False rec.price = pricelist.include_price or 0.0 rec.tax_id = pricelist.taxes_product_id.id or pricelist.taxes_system_id.id or False @api.onchange('vendor_id', 'price', 'tax_id') def _onchange_auto_done(self): """Jika semua field wajib terisi, ubah state ke 'done'.""" for rec in self: if all([rec.vendor_id, rec.price, rec.tax_id]) and rec.state in ('draft', 'sourcing'): rec.state = 'done' class SourcingRejectWizard(models.TransientModel): _name = 'sourcing.reject.wizard' _description = 'Wizard untuk alasan reject SJO oleh Sales' sjo_id = fields.Many2one('sourcing.job.order', string='Sourcing Job Order') reason = fields.Text(string='Alasan Penolakan', required=True) def action_confirm_reject(self): self.ensure_one() sjo = self.sjo_id # Reset approval sales sjo.approval_sales = 'reject' sjo.is_priority = True # Hapus semua aktivitas terkait activities = self.env['mail.activity'].search([ ('res_model', '=', sjo._name), ('res_id', '=', sjo.id), ]) activities.unlink() # Posting reason ke log note sjo.message_post( body=f"โŒ {self.env.user.name} menolak approval untuk SJO '{sjo.name}'.
" f"Alasan: {self.reason}", subtype_xmlid="mail.mt_comment" ) # Kirim notifikasi ke user self.env.user.notify_info( message=f"Approval SJO '{sjo.name}' telah ditolak. MD dapat melakukan Sourcing ulang.", title="Approval Ditolak", ) return {'type': 'ir.actions.client', 'tag': 'reload'} class WizardExportSJOtoSO(models.TransientModel): _name = "wizard.export.sjo.to.so" _description = "Wizard Export SJO Products to SO" sjo_id = fields.Many2one("sourcing.job.order") line_ids = fields.Many2many("product.product", string="Products") @api.model def default_get(self, fields): res = super().default_get(fields) sjo_id = self.env.context.get("default_sjo_id") if sjo_id: # Ambil product sudah convert products = self.env["product.product"].search([ ("sourcing_job_id", "=", sjo_id) ]) res["line_ids"] = [(6, 0, products.ids)] return res def action_confirm(self): self.ensure_one() sjo = self.sjo_id if not sjo.so_id: raise UserError("Sales Order belum dipilih di SJO!") so = sjo.so_id SaleOrderLine = self.env["sale.order.line"] for product in self.line_ids: so_line_new = SaleOrderLine.new({ "order_id": so.id, "product_id": product.id, }) so_line_new.product_id_change() vals = SaleOrderLine._convert_to_write(so_line_new._cache) new_line = SaleOrderLine.create(vals) sjo_line = self.env["sourcing.job.order.line"].search([ ("order_id", "=", sjo.id), ("code", "=", product.default_code) ], limit=1) if sjo_line: sjo_line.exported_to_so = True return { 'type': 'ir.actions.act_window', 'res_model': 'sale.order', 'view_mode': 'form', 'res_id': so.id, 'target': 'current', } class SourcingJobOrderLineImportWizard(models.TransientModel): _name = 'sourcing.job.order.line.import.wizard' _description = 'Import SJO Line from Excel' excel_file = fields.Binary("Excel File", required=True) filename = fields.Char("Filename") order_id = fields.Many2one('sourcing.job.order', string="Sourcing Job Order", required=True) def action_import_excel(self): if not self.excel_file: raise UserError(_("โš ๏ธ Harap upload file Excel terlebih dahulu.")) try: data = base64.b64decode(self.excel_file) book = xlrd.open_workbook(file_contents=data) sheet = book.sheet_by_index(0) except: raise UserError(_("โŒ Format Excel tidak valid atau rusak.")) header = [str(sheet.cell(0, col).value).strip() for col in range(sheet.ncols)] required_headers = [ 'Nama Barang', 'SKU', 'Expected Price', 'Note Sourcing', 'Brand', 'Deskripsi / Spesifikasi', 'SLA Product', 'Quantity Product', 'Purchase Price', 'Tax', 'Vendor', 'Product Category', 'Categories', 'Product Type' ] for req in required_headers: if req not in header: raise UserError(_("โŒ Kolom '%s' tidak ditemukan di file Excel.") % req) header_map = {h: idx for idx, h in enumerate(header)} lines_created = 0 ProductLine = self.env['sourcing.job.order.line'] Tax = self.env['account.tax'] Vendor = self.env['res.partner'] Category = self.env['product.category'] PublicCategory = self.env['product.public.category'] for row_idx in range(1, sheet.nrows): row = sheet.row(row_idx) def val(field): return str(sheet.cell(row_idx, header_map[field]).value).strip() if not val('Nama Barang'): continue # skip kosong # Relations tax = Tax.search([('name', 'ilike', val('Tax'))], limit=1) vendor = Vendor.search([('name', 'ilike', val('Vendor'))], limit=1) category = Category.search([('name', 'ilike', val('Product Category'))], limit=1) # Many2many: Categories class_names = val('Categories').split(';') class_ids = [] for name in class_names: name = name.strip() if name: pc = PublicCategory.search([('name', 'ilike', name)], limit=1) if pc: class_ids.append(pc.id) # Build values vals = { 'order_id': self.order_id.id, 'product_name': val('Nama Barang'), 'code': val('SKU'), 'budget': val('Expected Price'), 'note': val('Note Sourcing'), 'brand': val('Brand'), 'descriptions': val('Deskripsi / Spesifikasi'), 'sla': val('SLA Product'), 'quantity': float(val('Quantity Product') or 0), 'price': float(val('Purchase Price') or 0), 'tax_id': tax.id if tax else False, 'vendor_id': vendor.id if vendor else False, 'product_category': category.id if category else False, 'product_type': val('Product Type') or 'product', 'product_class': [(6, 0, class_ids)], } ProductLine.create(vals) lines_created += 1 return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': _('โœ… Import Selesai'), 'message': _('%s baris berhasil diimport.') % lines_created, 'type': 'success', 'sticky': False, } } class SourcingJobOrderLineExportWizard(models.TransientModel): _name = 'sourcing.job.order.line.export.wizard' _description = 'Export SJO Line Wizard' order_id = fields.Many2one('sourcing.job.order', string="Sourcing Job Order", required=True) file = fields.Binary("CSV File", readonly=True) filename = fields.Char("Filename", readonly=True) def action_export(self): if not self.order_id: raise UserError("Silakan pilih Sourcing Job Order terlebih dahulu.") lines = self.env['sourcing.job.order.line'].search([('order_id', '=', self.order_id.id)]) wb = xlwt.Workbook() sheet = wb.add_sheet("SJO Lines") headers = [ 'Nama Barang', 'SKU', 'Expected Price', 'Note Sourcing', 'Brand', 'Deskripsi / Spesifikasi', 'SLA Product', 'Quantity Product', 'Purchase Price', 'Tax', 'Vendor', 'Product Category', 'Categories', 'Product Type' ] # Write header for col, header in enumerate(headers): sheet.write(0, col, header) for row_idx, line in enumerate(lines, start=1): categories = '; '.join(line.product_class.mapped('name')) or '' values = [ line.product_name or '', line.code or '', line.budget or '', line.note or '', line.brand or '', line.descriptions or '', line.sla or '', line.quantity or 0, line.price or 0, line.tax_id.name if line.tax_id else '', line.vendor_id.name if line.vendor_id else '', line.product_category.name if line.product_category else '', categories, line.product_type or '', ] for col_idx, value in enumerate(values): sheet.write(row_idx, col_idx, value) # Save to binary fp = io.BytesIO() wb.save(fp) fp.seek(0) data = fp.read() fp.close() self.file = base64.b64encode(data) self.filename = f"SJO_{self.order_id.name}_lines.xls" # Note: xlwt hanya mendukung .xls return { 'type': 'ir.actions.act_window', 'res_model': self._name, 'view_mode': 'form', 'res_id': self.id, 'target': 'new', } class SourcingJobOrderLineTemplateWizard(models.TransientModel): _name = 'sourcing.job.order.line.template.wizard' _description = 'Download Template SJO Line' file = fields.Binary("File", readonly=True) filename = fields.Char("Filename", readonly=True) def action_generate_template(self): res = super().default_get(fields) output = io.BytesIO() wb = xlwt.Workbook() ws = wb.add_sheet('Template') headers = ['Nama Barang', 'SKU', 'Expected Price'] for idx, header in enumerate(headers): ws.write(0, idx, header) wb.save(output) output.seek(0) res.update({ 'file': base64.b64encode(output.read()), 'filename': 'sjo_template.xls', }) return res