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"
  • {line.product_name_md} - {line.price or 0}
  • " line.message_post( body=f"๐Ÿ“ค Request approval dikirim (Multi)", subtype_xmlid="mail.mt_comment", ) job.message_post( body=( f"๐Ÿ“ค Multi Request Approval
    " f"" f"MD: {self.env.user.name}" ), subtype_xmlid="mail.mt_comment", ) self.env.user.notify_success( message=f"{len(self)} produk berhasil dikirim untuk approval.", title="Multi Request Sent" ) # return {'type': 'ir.actions.client', 'tag': 'reload'} def action_ask_approval(self): bot_sjo = '8335015210:AAGbObP0jQf7ptyqJhYdBYn5Rm0CWOd_yIM' chat_sjo = '6076436058' api_base = f'https://api.telegram.org/bot{bot_sjo}/sendMessage' for line in self: job = line.order_id if line.md_person_ids != self.env.user: raise UserError("โŒ Hanya MD pada line ini yang dapat Request Approval.") if line.state != 'sourcing': raise UserError("โš ๏ธ Hanya line status 'Sourcing' yang bisa minta approval.") missing_fields = [] if not line.vendor_id: missing_fields.append("Vendor") if not line.product_name_md: missing_fields.append("Product Name") if not line.brand_id: missing_fields.append("Manufactures") if not line.price or line.price <= 0: missing_fields.append("Price") if not line.tax_id: missing_fields.append("Tax") if not line.subtotal or line.subtotal <= 0: missing_fields.append("Subtotal") if not line.product_type: missing_fields.append("Product Type") if not line.product_category: missing_fields.append("Product Category") if not line.product_class: missing_fields.append("Product Class") if missing_fields: raise UserError( "โŒ Lengkapi data berikut sebelum Ask Approval Sales:\n- " + "\n- ".join(missing_fields) ) line.state = 'sent' activity_type = self.env.ref('mail.mail_activity_data_todo') 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}'.", ) line.message_post( body=( f"๐Ÿ“ค Request approval dikirim
    " f"Kepada: {job.create_uid.name}
    " f"Produk: {line.product_name_md}" ), subtype_xmlid="mail.mt_comment" ) job.message_post( body=( f"๐Ÿ“ค Request approval line
    " f"" ), subtype_xmlid="mail.mt_comment" ) self.env.user.notify_success( message=f"Request approval untuk '{line.product_name_md}' dikirim ke {job.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={job.id}&model=sourcing.job.order&view_type=form" try: msg_text = ( f"๐Ÿ“ข Request Approval Produk\n\n" f"๐Ÿงพ Sourcing Job: ๐Ÿ“Ž {job.name}\n" f"๐Ÿ“ฆ Produk: {line.product_name_md}\n" f"๐Ÿ‘ค MD: {self.env.user.name}\n" f"๐Ÿ’ฐ Harga: {line.price or 0}\n" f"๐Ÿ“… Tanggal: {fields.Datetime.now().strftime('%d-%m-%Y %H:%M')}\n\n" f"Silakan review di Odoo." ) payload = { 'chat_id': chat_sjo, 'text': msg_text, 'parse_mode': 'HTML' } response = requests.post(api_base, data=payload, timeout=10) response.raise_for_status() except Exception as e: _logger.warning(f"Gagal kirim telegram approval line: {e}") return {'type': 'ir.actions.client', 'tag': 'reload'} def action_approve_approval(self): ProductProduct = self.env['product.product'] PurchasePricelist = self.env['purchase.pricelist'] SaleOrderLine = self.env['sale.order.line'] for rec in self: job = rec.order_id if job.create_uid != self.env.user: raise UserError("โŒ Hanya pembuat Sourcing Job yang bisa approve.") rec.write({'state': 'approve'}) product = False if rec.code: product = ProductProduct.search([ ('default_code', '=', rec.code), ('active', '=', True) ], limit=1) if product: rec.product_id = product.id self.env.user.notify_warning( message=f"SKU {rec.code} sudah ada. Tidak dibuat ulang.", title="SKU Exists" ) else: type_map = { 'servis': 'service', 'product': 'product', 'consu': 'consu', } product = ProductProduct.with_context(from_sourcing_approval=True).create({ 'name': rec.product_name_md, 'default_code': rec.code or False, 'description': rec.descriptions_md or '', 'type': type_map.get(rec.product_type, 'product'), 'categ_id': rec.product_category.id if rec.product_category else False, 'x_manufacture': rec.brand_id.id if rec.brand_id else False, 'standard_price': rec.price or 0, 'public_categ_ids': [(6, 0, rec.product_class.ids)] if rec.product_class else False, 'active': True, 'sourcing_job_id': job.id if job else False, }) if not rec.code: padded_id = str(product.id).zfill(7) sku_auto = f"IT.{padded_id}" product.default_code = sku_auto rec.code = sku_auto rec.product_id = product.id self.env.user.notify_success( message=f"Produk baru '{product.name}' berhasil dibuat dengan SKU {product.default_code}.", title="Product Created" ) jakarta_tz = rec.order_id._get_jakarta_today() purchase_price = PurchasePricelist.search([ ('product_id', '=', product.id), ('vendor_id', '=', rec.vendor_id.id), ], order="human_last_update desc", limit=1) pricelist_vals = { 'product_id': product.id, 'vendor_id': rec.vendor_id.id, 'product_price': rec.price or 0, 'include_price': rec.price or 0, 'taxes_product_id': rec.tax_id.id if rec.tax_id else False, 'brand_id': product.x_manufacture.id if product.x_manufacture else False, 'human_last_update': jakarta_tz, 'is_winner': True, } if not purchase_price: PurchasePricelist.create(pricelist_vals) elif purchase_price.product_price != (rec.price or 0): purchase_price.write(pricelist_vals) if rec.so_id and not rec.exported_to_so: so = rec.so_id so_line_new = SaleOrderLine.new({ "order_id": so.id, "product_id": product.id, "product_uom_qty": rec.quantity or 1, "price_unit": rec.price or 0, "name": rec.product_name_md, }) so_line_new.product_id_change() vals = SaleOrderLine._convert_to_write(so_line_new._cache) SaleOrderLine.create(vals) rec.exported_to_so = True activities = self.env['mail.activity'].search([ ('res_model', '=', rec._name), ('res_id', '=', rec.id), ]) activities.unlink() rec.message_post( body=( f"โœ… Approval disetujui oleh {self.env.user.name}
    " f"Produk siap untuk proses selanjutnya (convert / PO / SO)." ), subtype_xmlid="mail.mt_comment" ) job.message_post( body=( f"โœ… Approval produk disetujui
    " f"" ), subtype_xmlid="mail.mt_comment" ) if rec.md_person_ids: rec.md_person_ids.notify_success( message=f"Produk '{rec.product_name_md}' telah di-approve sales.", title="Approval Approved" ) self.env.user.notify_success( message=f"Produk '{rec.product_name_md}' berhasil di-approve.", title="Approved" ) so = self.mapped('so_id')[:1] if so: return { 'type': 'ir.actions.act_window', 'name': 'Sales Order', 'res_model': 'sale.order', 'view_mode': 'form', 'res_id': so.id, 'target': 'current', } return {'type': 'ir.actions.client', 'tag': 'reload'} def action_multi_approve(self): so_ids = self.mapped('so_id').ids if len(set(so_ids)) > 1: raise UserError("โŒ Multi approve hanya bisa dilakukan jika semua line berasal dari Sales Order yang sama.") self.action_approve_approval() def action_reject_approval(self): self.ensure_one() job = self.order_id if job.create_uid != self.env.user: raise UserError("โŒ Hanya pembuat Sourcing Job yang bisa reject approval.") return { 'name': 'Reason Reject', 'type': 'ir.actions.act_window', 'res_model': 'sourcing.reject.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_line_id': self.id } } def action_cancel(self): for rec in self: if self.env.user != rec.md_person_ids: raise UserError("Hanya MD Person Job ini yang bisa Cancel.") if not rec.reason: raise UserError("Isi Reason untuk Cancel Job.") 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.write({'state': 'cancel'}) rec.message_post( body=( "Line %s dari Order %s di Cancel oleh %s
    " "Reason: %s" ) % ( line_no, rec.order_id.name or '-', self.env.user.name, rec.reason or '-' ) ) @api.onchange('product_id') def _oncange_code(self): for rec in self: if not rec.product_id: continue product = rec.product_id if not product: return template = product.product_tmpl_id attribute_values = product.product_template_attribute_value_ids.mapped( 'product_attribute_value_id.name' ) attribute_values_str = ', '.join(attribute_values) if attribute_values else '' # generate line name line_name = ( ('[' + product.default_code + '] ' if product.default_code else '') + (product.name or '') + (' (' + attribute_values_str + ')' if attribute_values_str else '') + (' ' + product.short_spesification if product.short_spesification else '') ) rec.code = product.default_code or rec.code rec.product_name_md = product.name or rec.product_name_md rec.descriptions_md = line_name.strip() or rec.descriptions_md rec.product_type = template.type or rec.product_type rec.brand_id = product.x_manufacture.id or rec.brand_id 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.now_price = pricelist.include_price or 0.0 rec.last_updated_price = pricelist.write_date or 0.0 rec.tax_id = pricelist.taxes_product_id.id or pricelist.taxes_system_id.id or False @api.onchange('attachment_type') def _onchange_attachment_type(self): for rec in self: if rec.attachment_type == 'pdf': rec.product_attachment_img = False rec.product_attachment_other = False elif rec.attachment_type == 'img': rec.product_attachment_pdf = False rec.product_attachment_other = False elif rec.attachment_type == 'other': rec.product_attachment_pdf = False rec.product_attachment_img = False else: rec.product_attachment_pdf = False rec.product_attachment_img = False rec.product_attachment_other = False @api.onchange( 'product_attachment_pdf', 'product_attachment_img', 'product_attachment_other', 'attachment_type' ) def _onchange_set_filename(self): for rec in self: sjo_number = rec.order_id.name if rec.order_id and rec.order_id.name else 'SJO' if rec.attachment_type == 'pdf' and rec.product_attachment_pdf: rec.product_attachment_filename = f"{sjo_number}.pdf" elif rec.attachment_type == 'img' and rec.product_attachment_img: rec.product_attachment_filename = f"{sjo_number}.png" elif rec.attachment_type == 'other' and rec.product_attachment_other: rec.product_attachment_filename = f"{sjo_number}_file" def action_reopen_cancel(self): self.ensure_one() if self.state != 'cancel': raise UserError("Cuma line cancel yang bisa direopen.") if self.order_id.create_uid != self.env.user: raise UserError("Cuma Pemilik SJO yang bisa Re-Open.") return { 'name': 'Reason Reopen', 'type': 'ir.actions.act_window', 'res_model': 'reopen.cancel.line.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_line_id': self.id } } def action_open_give_wizard(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': 'Give To MD', 'res_model': 'sjo.give.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_line_id': self.id, } } def action_open_reject_given_wizard(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': 'Reject Request Give SJO Line', 'res_model': 'sjo.reject.give.wizard', 'view_mode': 'form', 'target': 'new', 'context': { 'default_line_id': self.id, } } def action_take_given(self): for rec in self: if self.env.user != rec.given_to_id: raise UserError("Hanya MD yang diberikan Request yang bisa Take Sourcing") old_owner = rec.previous_md_id.name new_owner = rec.given_to_id.name receiver = rec.given_to_id rec.with_context(bypass_md_check=True).write({ 'md_person_ids': rec.given_to_id.id, 'given_to_id': False, 'previous_md_id': False, 'is_given': False, }) rec._unlink_give_activity(receiver) rec.message_post( body=f"{new_owner} Menerima Request Sourcing dari {old_owner}" ) class SjoGiveWizard(models.TransientModel): _name = 'sjo.give.wizard' _description = 'Give SJO Line Wizard' line_id = fields.Many2one('sourcing.job.order.line') md_id = fields.Many2one('res.users', string='Give To', required=True, domain=lambda self: [ ('groups_id', 'in', self.env.ref('base.group_user').ids), ('groups_id', 'in', self.env.ref('indoteknik_custom.group_role_merchandiser').ids), ('active', '=', True) ]) def action_confirm(self): self.ensure_one() line = self.line_id if self.env.user != line.md_person_ids: raise UserError("Hanya Md Target yang bisa Confirm Give Sourcing") old_owner = line.md_person_ids.name new_owner = self.md_id.name line.write({ 'previous_md_id': line.md_person_ids.id, 'given_to_id': self.md_id.id, 'is_given': True, }) activity_type = self.env.ref('mail.mail_activity_data_todo') line.activity_schedule( activity_type_id=activity_type.id, user_id=self.md_id.id, note="SJO Line diberikan ke Anda. Silakan Take atau Reject.", ) line.message_post( body=f""" MD {old_owner} Mengirim Request Peralihan Sourcing Ke {new_owner} """, subtype_xmlid="mail.mt_comment" ) class SjoRejectGiveWizard(models.TransientModel): _name = 'sjo.reject.give.wizard' _description = 'Reject Given SJO Line Wizard' line_id = fields.Many2one('sourcing.job.order.line', required=True) reason = fields.Text(string="Reject Reason", required=True) def action_confirm(self): self.ensure_one() line = self.line_id if self.env.user != line.given_to_id: raise UserError("Hanya Penerima Request yang bisa Reject Give") from_md = line.previous_md_id.name or "-" receiver = line.given_to_id rejector = self.env.user.name line._unlink_give_activity(receiver) line.with_context(bypass_md_check=True).write({ 'given_to_id': False, 'is_given': False, }) line.message_post( body=f""" Request Peralihan dari {from_md} Rejected by {rejector}
    Alasan: {self.reason} """, subtype_xmlid="mail.mt_comment" ) class WizardExportSJOtoSO(models.TransientModel): _name = "wizard.export.sjo.to.so" _description = "Wizard Export SJO Products to SO" sjo_id = fields.Many2one("sourcing.job.order", string="Sourcing Job ID") line_ids = fields.Many2many("sourcing.job.order.line", string="SJO Lines") product_ids = fields.Many2many( "product.product", string="Products", compute="_compute_products", store=False, ) @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 line yg punya product & belum di export lines = self.env["sourcing.job.order.line"].search([ ("order_id", "=", sjo_id), ("product_id", "!=", False), ("exported_to_so", "=", False), ("state", "=", "approve"), # optional: cuma yg done ]) res["line_ids"] = [(6, 0, lines.ids)] return res @api.depends("line_ids") def _compute_products(self): for rec in self: rec.product_ids = rec.line_ids.mapped("product_id") 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 line in self.line_ids: if not line.product_id: continue # bikin SOL dari product so_line_new = SaleOrderLine.new({ "order_id": so.id, "product_id": line.product_id.id, "product_uom_qty": line.quantity or 1, "price_unit": line.price or 0, "name": line.product_name, }) so_line_new.product_id_change() vals = SaleOrderLine._convert_to_write(so_line_new._cache) new_line = SaleOrderLine.create(vals) # tandai sudah export 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 & Import Template SJO Line' file = fields.Binary("Template", readonly=True) filename = fields.Char("Filename", readonly=True) order_id = fields.Many2one( 'sourcing.job.order', string="Sourcing Job Order", required=True, domain="[('state', '=', 'taken')]", default=lambda self: self.env.context.get('active_id') ) excel_file = fields.Binary("Upload Excel") excel_filename = fields.Char("Excel Filename") def action_generate_template(self): output = io.BytesIO() wb = xlwt.Workbook() ws = wb.add_sheet('Template') 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 col, header in enumerate(headers): ws.write(0, col, header) wb.save(output) output.seek(0) self.file = base64.b64encode(output.read()) self.filename = "SJO_import_template.xls" return { 'type': 'ir.actions.act_window', 'res_model': self._name, 'view_mode': 'form', 'res_id': self.id, 'target': 'new', } def action_import_excel(self): if not self.excel_file: raise UserError(_("โš ๏ธ Harap upload file Excel terlebih dahulu.")) if not self.order_id: raise UserError(_("โš ๏ธ Pilih Sourcing Job Order dulu.")) 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): def val(field): return str(sheet.cell(row_idx, header_map[field]).value).strip() if not val('Nama Barang'): continue 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) vals = { 'order_id': self.order_id.id, 'product_name': val('Nama Barang'), 'code': val('SKU'), 'budget': float(val('Expected Price') or 0), '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)], 'state': 'sourcing', 'md_person_id': self.env.user, } 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 SourcingRejectWizard(models.TransientModel): _name = 'sourcing.reject.wizard' _description = 'Wizard alasan reject produk sourcing oleh sales' line_id = fields.Many2one('sourcing.job.order.line', string='Sourcing Line', required=True) reason = fields.Text(string='Alasan Penolakan', required=True) def action_confirm_reject(self): self.ensure_one() line = self.line_id job = line.order_id line.state = 'sourcing' activities = self.env['mail.activity'].search([ ('res_model', '=', line._name), ('res_id', '=', line.id), ]) activities.unlink() line.message_post( body=( f"โŒ Approval ditolak oleh {self.env.user.name}
    " f"Produk: {line.product_name}
    " f"Alasan:
    {self.reason}" ), subtype_xmlid="mail.mt_comment" ) job.message_post( body=( f"โŒ Approval produk ditolak
    " f"
      " f"
    • Produk: {line.product_name}
    • " f"
    • Ditolak oleh: {self.env.user.name}
    • " f"
    • Alasan: {self.reason}
    • " f"
    " ), subtype_xmlid="mail.mt_comment" ) if line.md_person_ids: line.md_person_ids.notify_warning( message=f"Produk '{line.product_name}' direject sales. Silakan sourcing ulang.", title="Approval Ditolak" ) self.env.user.notify_info( message=f"Produk '{line.product_name}' berhasil direject.", title="Rejected" ) return {'type': 'ir.actions.client', 'tag': 'reload'} class ReopenCancelLineWizard(models.TransientModel): _name = 'reopen.cancel.line.wizard' _description = 'Reopen Cancel Line Reason' line_id = fields.Many2one('sourcing.job.order.line', required=True) reason = fields.Text(required=True, string="Reason Reopen") def action_confirm(self): self.ensure_one() line = self.line_id if line.order_id.create_uid != self.env.user: raise UserError("Line ini bukan bagian dari SJO anda.") # post message dulu line.message_post( body=( "Line %s di REOPEN oleh %s
    " "Reason: %s" ) % ( line.product_id.display_name or '-', self.env.user.name, self.reason ) ) # reset field line.write({ 'state': 'draft', 'md_person_ids': False, })