From 7bfc92fdb73a89c5bc0b4c711315cbd5ea3ff268 Mon Sep 17 00:00:00 2001 From: HafidBuroiroh Date: Tue, 24 Feb 2026 08:47:38 +0700 Subject: sourcing job dari sale order ga include crm --- indoteknik_custom/models/__init__.py | 1 + indoteknik_custom/models/product_template.py | 5 + indoteknik_custom/models/sale_order.py | 14 +- indoteknik_custom/models/sourcing_job_order.py | 1406 ++++++++++++++++++++++++ 4 files changed, 1425 insertions(+), 1 deletion(-) create mode 100644 indoteknik_custom/models/sourcing_job_order.py (limited to 'indoteknik_custom/models') diff --git a/indoteknik_custom/models/__init__.py b/indoteknik_custom/models/__init__.py index a042750b..9d9c0ec5 100755 --- a/indoteknik_custom/models/__init__.py +++ b/indoteknik_custom/models/__init__.py @@ -167,3 +167,4 @@ from . import uom_uom from . import commission_internal from . import update_depreciation_move_wizard from . import keywords +from . import sourcing_job_order diff --git a/indoteknik_custom/models/product_template.py b/indoteknik_custom/models/product_template.py index 397bd06d..969c9765 100755 --- a/indoteknik_custom/models/product_template.py +++ b/indoteknik_custom/models/product_template.py @@ -947,6 +947,11 @@ class ProductProduct(models.Model): qty_pcs_box = fields.Float("Pcs Box") barcode_box = fields.Char("Barcode Box") keyword_id = fields.Many2one('keywords', string='Keyword') + sourcing_job_id = fields.Many2one( + "sourcing.job.order", + string="Sourcing Job", + readonly=True, + ) def _add_product_to_keywords(self,product): keywords_model = self.env['keywords'] diff --git a/indoteknik_custom/models/sale_order.py b/indoteknik_custom/models/sale_order.py index 90cd5fa2..3382d95b 100755 --- a/indoteknik_custom/models/sale_order.py +++ b/indoteknik_custom/models/sale_order.py @@ -3707,4 +3707,16 @@ class SaleOrder(models.Model): 'view_mode': 'tree,form', 'domain': [('id', 'in', moves.ids)], 'target': 'current', - } \ No newline at end of file + } + + def action_open_sjo(self): + return { + 'name': 'SJO', + 'type': 'ir.actions.act_window', + 'res_model': 'sourcing.job.order', + 'view_mode': 'form', + 'target': 'current', + 'context': { + 'default_so_id': self.id, + } + } diff --git a/indoteknik_custom/models/sourcing_job_order.py b/indoteknik_custom/models/sourcing_job_order.py new file mode 100644 index 00000000..e015eaaa --- /dev/null +++ b/indoteknik_custom/models/sourcing_job_order.py @@ -0,0 +1,1406 @@ +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.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.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.") + + # --- Simpan data lama sebelum write (buat pembanding) + 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() + + # --- 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('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_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_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') + 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') + 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', 'Approved'), + ('done', '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", + ) + + @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('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.") + + 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 == 'done': + 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 ['done', 'cancel'] for s in states) + any_done = any(s == 'done' for s in states) + any_progress = any(s not in ['done', '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}' bukan status Sourcing.") + + if ( + not line.vendor_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}' 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}' di SJO '{job.name}'.", + ) + + approved_lines_text += f"
  • {line.product_name} - {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.") + + if ( + not line.vendor_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("โŒ Lengkapi data sebelum Ask Approval sales") + + 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}' di SJO '{job.name}'.", + ) + + line.message_post( + body=( + f"๐Ÿ“ค Request approval dikirim
    " + f"Kepada: {job.create_uid.name}
    " + f"Produk: {line.product_name}" + ), + 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}' 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}\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): + 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.state = 'approve' + + 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}' telah di-approve sales.", + title="Approval Approved" + ) + + self.env.user.notify_success( + message=f"Produk '{rec.product_name}' berhasil di-approve.", + title="Approved" + ) + + return {'type': 'ir.actions.client', 'tag': 'reload'} + + 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_convert(self): + ProductProduct = self.env['product.product'] + PurchasePricelist = self.env['purchase.pricelist'] + + for line in self: + job = line.order_id + + if line.md_person_ids and line.md_person_ids != self.env.user: + raise UserError("โŒ Hanya MD Person pada line ini yang bisa convert.") + + if line.state != 'approve': + raise UserError("โš ๏ธ Convert hanya bisa setelah sales approve.") + + existing = False + if line.code: + existing = ProductProduct.search([('default_code', '=', line.code)], limit=1) + + if existing: + line.product_id = existing.id + line.state = 'done' + + job.message_post( + body=f"โ„น๏ธ SKU {line.code} sudah ada. Produk existing dipakai.", + subtype_xmlid="mail.mt_comment", + ) + + line.message_post( + body=( + f"โ„น๏ธ SKU sudah ada di sistem
    " + f"Produk existing: {existing.name}
    " + f"Tidak dibuat ulang, langsung linked." + ), + subtype_xmlid="mail.mt_comment", + ) + + self.env.user.notify_warning( + message=f"SKU {line.code} sudah ada. Tidak dibuat ulang.", + title="SKU Exists" + ) + 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 or 0, + 'public_categ_ids': [(6, 0, line.product_class.ids)] if line.product_class else False, + 'active': True, + 'sourcing_job_id': job.id if job else False, + }) + + if not line.code: + padded_id = str(new_product.id).zfill(7) + sku_auto = f"IT.{padded_id}" + new_product.default_code = sku_auto + line.code = sku_auto + + line.product_id = new_product.id + + jakarta_tz = line.order_id._get_jakarta_today() + + pricelist_vals = { + 'product_id': new_product.id, + 'vendor_id': line.vendor_id.id, + 'system_price': line.price or 0, + 'product_price': line.price or 0, + 'include_price': line.price or 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, + } + + PurchasePricelist.create(pricelist_vals) + + job.message_post( + body=( + f"๐Ÿ†• Produk berhasil dibuat dari sourcing line
    " + f"" + f"Produk sudah masuk ke master product & pricelist." + ), + subtype_xmlid="mail.mt_comment" + ) + line.message_post( + body=( + f"โœ… Produk berhasil dikonversi
    " + f"" + f"Produk sudah masuk ke master product & pricelist." + ), + subtype_xmlid="mail.mt_comment" + ) + + line.state = 'done' + + self.env.user.notify_success( + message=f"Produk {new_product.name} berhasil dikonversi.", + title="Convert Success" + ) + + return {'type': 'ir.actions.client', 'tag': 'reload'} + + 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 + + rec.code = product.default_code or rec.code + 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.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 + } + } + +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", "=", "done"), # 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"" + ), + 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, + }) \ No newline at end of file -- cgit v1.2.3 From f687d197ead268040d7f396eb26ea0035a6dac35 Mon Sep 17 00:00:00 2001 From: HafidBuroiroh Date: Fri, 6 Mar 2026 14:24:00 +0700 Subject: change request sourcing job order --- indoteknik_custom/models/sourcing_job_order.py | 475 +++++++++++++++++-------- 1 file changed, 317 insertions(+), 158 deletions(-) (limited to 'indoteknik_custom/models') diff --git a/indoteknik_custom/models/sourcing_job_order.py b/indoteknik_custom/models/sourcing_job_order.py index e015eaaa..6bb59c62 100644 --- a/indoteknik_custom/models/sourcing_job_order.py +++ b/indoteknik_custom/models/sourcing_job_order.py @@ -158,7 +158,6 @@ class SourcingJobOrder(models.Model): 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.") - # --- Simpan data lama sebelum write (buat pembanding) old_data = {} for rec in self: old_data[rec.id] = { @@ -179,18 +178,15 @@ class SourcingJobOrder(models.Model): 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('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_ids: old_line = old_lines.get(line.id) @@ -221,7 +217,6 @@ class SourcingJobOrder(models.Model): joined = "
    ".join(sub_changes) changes.append(f"{line.product_name}:
    {joined}") - # Post ke chatter if changes: message = "

    ".join(changes) rec.message_post( @@ -278,11 +273,16 @@ class SourcingJobOrderLine(models.Model): 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') - product_name = fields.Char(string='Nama Barang', required=True) + brand_id = fields.Many2one('x_manufactures', string='Manufactures', ondelete='cascade') + so_id = fields.Many2one('sale.order', string='SO Number', tracking=True, readonly=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') - brand = fields.Char(string='Brand') attachment_type = fields.Selection([ ('none', 'None'), ('pdf', '.PDF'), @@ -293,6 +293,7 @@ class SourcingJobOrderLine(models.Model): 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') @@ -309,8 +310,7 @@ class SourcingJobOrderLine(models.Model): ('draft', 'Unsource'), ('sourcing', 'On Sourcing'), ('sent', 'Approval Sent'), - ('approve', 'Approved'), - ('done', 'Done Sourcing'), + ('approve', 'Done Sourcing'), ('cancel', 'Unavailable') ], default='draft', tracking=True) product_type = fields.Selection([ @@ -323,16 +323,68 @@ class SourcingJobOrderLine(models.Model): string="Show for Sales", compute="_compute_show_for_sales", ) + show_salesperson = fields.Many2one( + 'res.users', + string="Salesperson", + ) + + so_state = fields.Selection( + [ + ('draft', 'Draft'), + ('cancel', 'Cancel'), + ('sale', 'Sale') + ], + string="SO State", + 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): - """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) + 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: @@ -356,6 +408,9 @@ class SourcingJobOrderLine(models.Model): 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 @@ -388,7 +443,7 @@ class SourcingJobOrderLine(models.Model): if total == 1: line = lines[0] - if line.state == 'done': + if line.state == 'approve': order.state = 'done' elif line.state == 'cancel': order.state = 'cancel' @@ -399,9 +454,9 @@ class SourcingJobOrderLine(models.Model): states = lines.mapped('state') all_cancel = all(s == 'cancel' for s in states) - all_done_or_cancel = all(s in ['done', 'cancel'] for s in states) - any_done = any(s == 'done' for s in states) - any_progress = any(s not in ['done', 'cancel', 'draft'] 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' @@ -496,10 +551,12 @@ class SourcingJobOrderLine(models.Model): for line in self: if line.state != 'sourcing': - raise UserError(f"โš ๏ธ Produk '{line.product_name}' bukan status 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 @@ -507,7 +564,7 @@ class SourcingJobOrderLine(models.Model): or not line.product_category or not line.product_class ): - raise UserError(f"โŒ Data produk '{line.product_name}' belum lengkap.") + raise UserError(f"โŒ Data produk '{line.product_name_md}' belum lengkap.") activity_type = self.env.ref('mail.mail_activity_data_todo') @@ -519,10 +576,10 @@ class SourcingJobOrderLine(models.Model): 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}' di SJO '{job.name}'.", + 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} - {line.price or 0}
  • " + approved_lines_text += f"
  • {line.product_name_md} - {line.price or 0}
  • " line.message_post( body=f"๐Ÿ“ค Request approval dikirim (Multi)", @@ -543,7 +600,7 @@ class SourcingJobOrderLine(models.Model): title="Multi Request Sent" ) - return {'type': 'ir.actions.client', 'tag': 'reload'} + # return {'type': 'ir.actions.client', 'tag': 'reload'} def action_ask_approval(self): @@ -562,6 +619,8 @@ class SourcingJobOrderLine(models.Model): if ( not line.vendor_id + or not line.product_name_md + or not line.brand_id or not line.price or line.price <= 0 or not line.tax_id or not line.subtotal or line.subtotal <= 0 @@ -578,14 +637,14 @@ class SourcingJobOrderLine(models.Model): 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}' di SJO '{job.name}'.", + 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}" + f"Produk: {line.product_name_md}" ), subtype_xmlid="mail.mt_comment" ) @@ -594,7 +653,7 @@ class SourcingJobOrderLine(models.Model): body=( f"๐Ÿ“ค Request approval line
    " f"
      " - f"
    • Produk: {line.product_name}
    • " + f"
    • Produk: {line.product_name_md}
    • " f"
    • MD: {self.env.user.name}
    • " f"
    • Vendor: {line.vendor_id.display_name if line.vendor_id else '-'}
    • " f"
    • Harga: {line.price or 0}
    • " @@ -604,7 +663,7 @@ class SourcingJobOrderLine(models.Model): ) self.env.user.notify_success( - message=f"Request approval untuk '{line.product_name}' dikirim ke {job.create_uid.name}", + message=f"Request approval untuk '{line.product_name_md}' dikirim ke {job.create_uid.name}", title="Request Sent", ) @@ -615,7 +674,7 @@ class SourcingJobOrderLine(models.Model): msg_text = ( f"๐Ÿ“ข Request Approval Produk\n\n" f"๐Ÿงพ Sourcing Job: ๐Ÿ“Ž {job.name}\n" - f"๐Ÿ“ฆ Produk: {line.product_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" @@ -637,13 +696,101 @@ class SourcingJobOrderLine(models.Model): 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.state = '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.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 + + 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), @@ -663,7 +810,7 @@ class SourcingJobOrderLine(models.Model): body=( f"โœ… Approval produk disetujui
      " f"
        " - f"
      • Produk: {rec.product_name}
      • " + f"
      • Produk: {rec.product_name_md}
      • " f"
      • Vendor: {rec.vendor_id.display_name if rec.vendor_id else '-'}
      • " f"
      • Harga: {rec.price or 0}
      • " f"
      • Disetujui oleh: {self.env.user.name}
      • " @@ -674,17 +821,20 @@ class SourcingJobOrderLine(models.Model): if rec.md_person_ids: rec.md_person_ids.notify_success( - message=f"Produk '{rec.product_name}' telah di-approve sales.", + 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}' berhasil di-approve.", + message=f"Produk '{rec.product_name_md}' berhasil di-approve.", title="Approved" ) return {'type': 'ir.actions.client', 'tag': 'reload'} + def action_multi_approve(self): + self.action_approve_approval() + def action_reject_approval(self): self.ensure_one() @@ -703,132 +853,6 @@ class SourcingJobOrderLine(models.Model): } } - def action_convert(self): - ProductProduct = self.env['product.product'] - PurchasePricelist = self.env['purchase.pricelist'] - - for line in self: - job = line.order_id - - if line.md_person_ids and line.md_person_ids != self.env.user: - raise UserError("โŒ Hanya MD Person pada line ini yang bisa convert.") - - if line.state != 'approve': - raise UserError("โš ๏ธ Convert hanya bisa setelah sales approve.") - - existing = False - if line.code: - existing = ProductProduct.search([('default_code', '=', line.code)], limit=1) - - if existing: - line.product_id = existing.id - line.state = 'done' - - job.message_post( - body=f"โ„น๏ธ SKU {line.code} sudah ada. Produk existing dipakai.", - subtype_xmlid="mail.mt_comment", - ) - - line.message_post( - body=( - f"โ„น๏ธ SKU sudah ada di sistem
        " - f"Produk existing: {existing.name}
        " - f"Tidak dibuat ulang, langsung linked." - ), - subtype_xmlid="mail.mt_comment", - ) - - self.env.user.notify_warning( - message=f"SKU {line.code} sudah ada. Tidak dibuat ulang.", - title="SKU Exists" - ) - 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 or 0, - 'public_categ_ids': [(6, 0, line.product_class.ids)] if line.product_class else False, - 'active': True, - 'sourcing_job_id': job.id if job else False, - }) - - if not line.code: - padded_id = str(new_product.id).zfill(7) - sku_auto = f"IT.{padded_id}" - new_product.default_code = sku_auto - line.code = sku_auto - - line.product_id = new_product.id - - jakarta_tz = line.order_id._get_jakarta_today() - - pricelist_vals = { - 'product_id': new_product.id, - 'vendor_id': line.vendor_id.id, - 'system_price': line.price or 0, - 'product_price': line.price or 0, - 'include_price': line.price or 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, - } - - PurchasePricelist.create(pricelist_vals) - - job.message_post( - body=( - f"๐Ÿ†• Produk berhasil dibuat dari sourcing line
        " - f"
          " - f"
        • Produk: {new_product.name}
        • " - f"
        • SKU: {line.code}
        • " - f"
        • Vendor: {line.vendor_id.display_name if line.vendor_id else '-'}
        • " - f"
        • Harga: {line.price or 0}
        • " - f"
        " - f"Produk sudah masuk ke master product & pricelist." - ), - subtype_xmlid="mail.mt_comment" - ) - line.message_post( - body=( - f"โœ… Produk berhasil dikonversi
        " - f"
          " - f"
        • Nama Produk: {new_product.name}
        • " - f"
        • SKU: {line.code}
        • " - f"
        • Vendor: {line.vendor_id.display_name if line.vendor_id else '-'}
        • " - f"
        • Harga Beli: {line.price or 0}
        • " - f"
        " - f"Produk sudah masuk ke master product & pricelist." - ), - subtype_xmlid="mail.mt_comment" - ) - - line.state = 'done' - - self.env.user.notify_success( - message=f"Produk {new_product.name} berhasil dikonversi.", - title="Convert Success" - ) - - return {'type': 'ir.actions.client', 'tag': 'reload'} - def action_cancel(self): for rec in self: if self.env.user != rec.md_person_ids: @@ -872,11 +896,24 @@ class SourcingJobOrderLine(models.Model): 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 = product.name or rec.product_name + 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 = product.x_manufacture.x_name or rec.brand + 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 [] @@ -946,6 +983,128 @@ class SourcingJobOrderLine(models.Model): } } + 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" @@ -970,7 +1129,7 @@ class WizardExportSJOtoSO(models.TransientModel): ("order_id", "=", sjo_id), ("product_id", "!=", False), ("exported_to_so", "=", False), - ("state", "=", "done"), # optional: cuma yg done + ("state", "=", "approve"), # optional: cuma yg done ]) res["line_ids"] = [(6, 0, lines.ids)] -- cgit v1.2.3 From f58fe20f96995228651a5a1a09c8c17a23e13838 Mon Sep 17 00:00:00 2001 From: HafidBuroiroh Date: Tue, 10 Mar 2026 10:46:23 +0700 Subject: final change request --- indoteknik_custom/models/product_template.py | 6 +- indoteknik_custom/models/sale_order.py | 18 ++++ indoteknik_custom/models/sourcing_job_order.py | 126 ++++++++++++++++++------- 3 files changed, 115 insertions(+), 35 deletions(-) (limited to 'indoteknik_custom/models') diff --git a/indoteknik_custom/models/product_template.py b/indoteknik_custom/models/product_template.py index 56022d06..ecaf9106 100755 --- a/indoteknik_custom/models/product_template.py +++ b/indoteknik_custom/models/product_template.py @@ -91,7 +91,8 @@ class ProductTemplate(models.Model): group_id = self.env.ref('indoteknik_custom.group_role_merchandiser').id users_in_group = self.env['res.users'].search([('groups_id', 'in', [group_id])]) active_model = self.env.context.get('active_model') - if self.env.user.id not in users_in_group.mapped('id') and active_model == None: + from_sourcing = self.env.context.get('from_sourcing_approval') + if self.env.user.id not in users_in_group.mapped('id') and active_model == None and not from_sourcing: raise UserError('Hanya MD yang bisa membuat Product') result = super(ProductTemplate, self).create(vals) return result @@ -983,7 +984,8 @@ class ProductProduct(models.Model): group_id = self.env.ref('indoteknik_custom.group_role_merchandiser').id active_model = self.env.context.get('active_model') users_in_group = self.env['res.users'].search([('groups_id', 'in', [group_id])]) - if self.env.user.id not in users_in_group.mapped('id') and active_model == None: + from_sourcing = self.env.context.get('from_sourcing_approval') + if self.env.user.id not in users_in_group.mapped('id') and active_model == None and not from_sourcing: raise UserError('Hanya MD yang bisa membuat Product') result = super(ProductProduct, self).create(vals) return result diff --git a/indoteknik_custom/models/sale_order.py b/indoteknik_custom/models/sale_order.py index 031007ae..567259af 100755 --- a/indoteknik_custom/models/sale_order.py +++ b/indoteknik_custom/models/sale_order.py @@ -404,6 +404,7 @@ class SaleOrder(models.Model): client_order_ref = fields.Char(tracking=True) + sourcing_job_count = fields.Integer(string='Sourcing Count', compute='_compute_sourcing_count') def action_set_shipping_id(self): for rec in self: @@ -3695,6 +3696,17 @@ class SaleOrder(models.Model): 'context': {'default_sale_order_ids': [self.id]}, } + def action_view_related_sjo(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Sourcing Job', + 'res_model': 'sourcing.job.order', + 'view_mode': 'tree,form', + 'domain': [('so_id', '=', self.id)], + 'context': {'default_so_id': self.id}, + } + def _compute_refund_ids(self): for order in self: refunds = self.env['refund.sale.order'].search([ @@ -3708,6 +3720,12 @@ class SaleOrder(models.Model): ('sale_order_ids', 'in', order.id) ]) + def _compute_sourcing_count(self): + for order in self: + order.sourcing_job_count = self.env['sourcing.job.order'].search_count([ + ('so_id', '=', order.id) + ]) + @api.depends('invoice_ids') def _compute_advance_payment_move(self): for order in self: diff --git a/indoteknik_custom/models/sourcing_job_order.py b/indoteknik_custom/models/sourcing_job_order.py index 6bb59c62..0e5334a8 100644 --- a/indoteknik_custom/models/sourcing_job_order.py +++ b/indoteknik_custom/models/sourcing_job_order.py @@ -89,11 +89,13 @@ class SourcingJobOrder(models.Model): @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: + + 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' @@ -101,22 +103,15 @@ class SourcingJobOrder(models.Model): 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' + 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 = '๐ŸŸก On Track' + 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): @@ -413,6 +408,7 @@ class SourcingJobOrderLine(models.Model): vals['show_salesperson'] = order.so_id.user_id.id rec = super().create(vals) + rec._check_line_limit() return rec def write(self, vals): @@ -427,8 +423,30 @@ class SourcingJobOrderLine(models.Model): res = super().write(vals) if 'state' in vals: self._update_parent_state() + self._check_line_limit() return res + def _check_line_limit(self): + for rec in self: + if not rec.order_id or not rec.order_id.so_id: + continue + + so = rec.order_id.so_id + + so_line_count = len(so.order_line) + + sourcing_lines = self.search([ + ('order_id', '=', rec.order_id.id), + ('state', '!=', 'cancel') + ]) + + if len(sourcing_lines) > so_line_count: + raise UserError( + f"Jumlah Sourcing Line tidak boleh melebihi Sales Order Line.\n\n" + f"Sales Order Line : {so_line_count}\n" + f"Sourcing Line : {len(sourcing_lines)}" + ) + def _update_parent_state(self): for rec in self: order = rec.order_id @@ -617,18 +635,40 @@ class SourcingJobOrderLine(models.Model): if line.state != 'sourcing': raise UserError("โš ๏ธ Hanya line status 'Sourcing' yang bisa minta approval.") - if ( - not line.vendor_id - or not line.product_name_md - or not line.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("โŒ Lengkapi data sebelum Ask Approval sales") + 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' @@ -730,7 +770,7 @@ class SourcingJobOrderLine(models.Model): 'consu': 'consu', } - product = ProductProduct.create({ + 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 '', @@ -751,6 +791,11 @@ class SourcingJobOrderLine(models.Model): 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([ @@ -830,9 +875,24 @@ class SourcingJobOrderLine(models.Model): 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): -- cgit v1.2.3 From 4a200ee4e0caf44e78273215b12c3655655f4273 Mon Sep 17 00:00:00 2001 From: HafidBuroiroh Date: Wed, 11 Mar 2026 20:08:23 +0700 Subject: naekin sorcing job --- indoteknik_custom/models/sourcing_job_order.py | 31 ++++---------------------- 1 file changed, 4 insertions(+), 27 deletions(-) (limited to 'indoteknik_custom/models') diff --git a/indoteknik_custom/models/sourcing_job_order.py b/indoteknik_custom/models/sourcing_job_order.py index 0e5334a8..ce0bc4ec 100644 --- a/indoteknik_custom/models/sourcing_job_order.py +++ b/indoteknik_custom/models/sourcing_job_order.py @@ -269,7 +269,7 @@ class SourcingJobOrderLine(models.Model): 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, readonly=True) + 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') @@ -325,11 +325,11 @@ class SourcingJobOrderLine(models.Model): so_state = fields.Selection( [ - ('draft', 'Draft'), + ('draft', 'Quotation'), ('cancel', 'Cancel'), - ('sale', 'Sale') + ('sale', 'Sale Order') ], - string="SO State", + string="Status SO", compute="_compute_so_data" ) @@ -408,7 +408,6 @@ class SourcingJobOrderLine(models.Model): vals['show_salesperson'] = order.so_id.user_id.id rec = super().create(vals) - rec._check_line_limit() return rec def write(self, vals): @@ -423,30 +422,8 @@ class SourcingJobOrderLine(models.Model): res = super().write(vals) if 'state' in vals: self._update_parent_state() - self._check_line_limit() return res - def _check_line_limit(self): - for rec in self: - if not rec.order_id or not rec.order_id.so_id: - continue - - so = rec.order_id.so_id - - so_line_count = len(so.order_line) - - sourcing_lines = self.search([ - ('order_id', '=', rec.order_id.id), - ('state', '!=', 'cancel') - ]) - - if len(sourcing_lines) > so_line_count: - raise UserError( - f"Jumlah Sourcing Line tidak boleh melebihi Sales Order Line.\n\n" - f"Sales Order Line : {so_line_count}\n" - f"Sourcing Line : {len(sourcing_lines)}" - ) - def _update_parent_state(self): for rec in self: order = rec.order_id -- cgit v1.2.3