diff options
| author | HafidBuroiroh <hafidburoiroh09@gmail.com> | 2025-10-30 10:12:10 +0700 |
|---|---|---|
| committer | HafidBuroiroh <hafidburoiroh09@gmail.com> | 2025-10-30 10:12:10 +0700 |
| commit | a41ed8ed1db45b019d3c3f7d31a01d9658c55f78 (patch) | |
| tree | 4d74ea77069a0b56bcde0df2c50007d9acdfdb77 /indoteknik_custom/models | |
| parent | 7160ae85c1655c99b2c8f1be77cb3cdb6616043e (diff) | |
| parent | 099bec753a310ec83ea3562a78c304dffb6d50d8 (diff) | |
merge apr
Diffstat (limited to 'indoteknik_custom/models')
| -rwxr-xr-x | indoteknik_custom/models/__init__.py | 1 | ||||
| -rw-r--r-- | indoteknik_custom/models/account_move.py | 1 | ||||
| -rw-r--r-- | indoteknik_custom/models/advance_payment_request.py | 1544 | ||||
| -rw-r--r-- | indoteknik_custom/models/partial_delivery.py | 14 | ||||
| -rwxr-xr-x | indoteknik_custom/models/purchase_order.py | 4 | ||||
| -rwxr-xr-x | indoteknik_custom/models/sale_order.py | 47 | ||||
| -rw-r--r-- | indoteknik_custom/models/solr/apache_solr_queue.py | 19 | ||||
| -rw-r--r-- | indoteknik_custom/models/solr/product_product.py | 4 | ||||
| -rw-r--r-- | indoteknik_custom/models/solr/product_template.py | 2 | ||||
| -rw-r--r-- | indoteknik_custom/models/sourcing_job_order.py | 367 | ||||
| -rw-r--r-- | indoteknik_custom/models/stock_move.py | 50 | ||||
| -rw-r--r-- | indoteknik_custom/models/stock_picking.py | 91 | ||||
| -rw-r--r-- | indoteknik_custom/models/tukar_guling.py | 8 |
13 files changed, 2059 insertions, 93 deletions
diff --git a/indoteknik_custom/models/__init__.py b/indoteknik_custom/models/__init__.py index 36a992d2..7f946b57 100755 --- a/indoteknik_custom/models/__init__.py +++ b/indoteknik_custom/models/__init__.py @@ -154,6 +154,7 @@ from . import approval_invoice_date from . import approval_payment_term from . import refund_sale_order # from . import patch +from . import advance_payment_request from . import tukar_guling from . import tukar_guling_po from . import update_date_planned_po_wizard diff --git a/indoteknik_custom/models/account_move.py b/indoteknik_custom/models/account_move.py index 44b3cb76..684ef335 100644 --- a/indoteknik_custom/models/account_move.py +++ b/indoteknik_custom/models/account_move.py @@ -106,6 +106,7 @@ class AccountMove(models.Model): help="Tanggal janji bayar dari customer setelah reminder dikirim.", tracking=True ) + internal_notes_contact = fields.Text(related='partner_id.comment', string="Internal Notes", readonly=True, help="Internal Notes dari contact utama customer.") # def _check_and_lock_cbd(self): # cbd_term = self.env['account.payment.term'].browse(26) diff --git a/indoteknik_custom/models/advance_payment_request.py b/indoteknik_custom/models/advance_payment_request.py new file mode 100644 index 00000000..5a465ca4 --- /dev/null +++ b/indoteknik_custom/models/advance_payment_request.py @@ -0,0 +1,1544 @@ +from odoo import models, api, fields, _ +from odoo.exceptions import UserError, ValidationError +from datetime import date, datetime, timedelta +# import datetime +import logging +_logger = logging.getLogger(__name__) +from terbilang import Terbilang +import pytz +from pytz import timezone +import base64 + + +class AdvancePaymentRequest(models.Model): + _name = 'advance.payment.request' + _description = 'Advance Payment Request or Reimburse' + _rec_name = 'number' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'create_date desc' + + user_id = fields.Many2one('res.users', string='Diajukan Oleh', default=lambda self: self.env.user, tracking=3) + partner_id = fields.Many2one('res.partner', string='Partner', related='user_id.partner_id', readonly=True) + + number = fields.Char(string='No. Dokumen', default='New Draft', tracking=3) + + applicant_name = fields.Many2one('res.users', string='Nama Pemohon', default=lambda self: self.env.user, required=True, tracking=3, domain="[('groups_id', 'in', [1])]") + # applicant_name = fields.One2many(string='Nama Pemohon', related='res.users') + nominal = fields.Float(string='Nominal', tracking=3, required=True) + + bank_name = fields.Char(string='Bank', tracking=3, required=True) + account_name = fields.Many2one('res.users', string='Nama Account', default=lambda self: self.env.user, required=True, tracking=3, domain="[('groups_id', 'in', [1])]") + # account_name = fields.Char(string='Nama Account', tracking=3, required=True) + bank_account = fields.Char(string='No. Rekening', tracking=3, required=True) + detail_note = fields.Text(string='Keterangan Penggunaan Rinci', tracking=3) + + date_back_to_office = fields.Date( + string='Tanggal Kembali ke Kantor', + tracking=3 + ) + + estimated_return_date = fields.Date( + string='Batas Pengajuan', + help='Tanggal batas maksimal durasi pengajuan realisasi' + ) + + days_remaining = fields.Integer( + string='Sisa Hari Realisasi', + compute='_compute_days_remaining', + help='Sisa hari batas maksimal pengajuan realisasi setelah kembali ke kantor. ' + '7 hari setelah tanggal kembali.' + ) + + status = fields.Selection([ + ('draft', 'Draft'), + ('pengajuan1', 'Menunggu Approval Departement'), + ('pengajuan2', 'Menunggu Approval AP'), + ('pengajuan3', 'Menunggu Approval Pimpinan'), + ('approved', 'Approved'), + ], string='Status', default='draft', tracking=3, index=True, track_visibility='onchange') + + + status_pay_down_payment = fields.Selection([ + ('pending', 'Pending'), + ('payment', 'Payment'), + ], string='Status Pembayaran', default='pending', tracking=3) + + name_approval_departement = fields.Char(string='Approval Departement') + name_approval_ap = fields.Char(string='Approval AP') + email_ap = fields.Char(string = 'Email AP') + name_approval_pimpinan = fields.Char(string='Approval Pimpinan') + + date_approved_department = fields.Datetime(string="Date Approved Department") + date_approved_ap = fields.Datetime(string="Date Approved AP") + date_approved_pimpinan = fields.Datetime(string="Date Approved Pimpinan") + + position_department = fields.Char(string='Position Departement') + position_ap = fields.Char(string='Position AP') + position_pimpinan = fields.Char(string='Position Pimpinan') + + approved_by = fields.Char(string='Approved By', tracking=True, track_visibility='always') + + departement_type = fields.Selection([ + ('sales', 'Sales'), + ('merchandiser', 'Merchandiser'), + ('marketing', 'Marketing'), + ('logistic', 'Logistic'), + ('procurement', 'Procurement'), + ('fat', 'FAT'), + ('it', 'IT'), + ('hr_ga', 'HR & GA'), + ('pimpinan', 'Pimpinan') + ], string='Departement Type', tracking=3, required=True) + + attachment_file_image = fields.Binary(string='Attachment Image', attachment_filename='attachment_filename_image') + attachment_file_pdf = fields.Binary(string='Attachment PDF', attachment_filename='attachment_filename_pdf') + attachment_filename_image = fields.Char(string='Filename Image') + attachment_filename_pdf = fields.Char(string='Filename PDF') + + attachment_type = fields.Selection([ + ('pdf', 'PDF'), + ('image', 'Image'), + ], string="Attachment Type") + + move_id = fields.Many2one('account.move', string='Journal Entries', domain=[('move_type', '=', 'entry')]) + is_cab_visible = fields.Boolean(string='Is Journal Uang Muka Visible', compute='_compute_is_cab_visible') + + + currency_id = fields.Many2one( + 'res.currency', string='Currency', + default=lambda self: self.env.company.currency_id + ) + + type_request = fields.Selection([ + ('pum', 'PUM'), + ('reimburse', 'Reimburse')], string='Tipe Pengajuan', tracking=3) + + position_type = fields.Selection([ + ('staff', 'Staff'), + ('manager', 'Manager'), + ('pimpinan', 'Pimpinan')], string='Jabatan') + + settlement_type = fields.Selection([ + ('no_settlement', 'Belum Realisasi'), + ('settlement', 'Realisasi') + ]) + + is_represented = fields.Boolean(string='Nama Pemohon Berbeda?', default=False) + + apr_perjalanan = fields.Boolean(string = "PUM Perjalanan?", default = False) + reimburse_line_ids = fields.One2many('reimburse.line', 'request_id', string='Rincian Reimburse') + upload_attachment_date = fields.Datetime(string='Upload Attachment Date', tracking=3) + settlement_ids = fields.One2many( + 'advance.payment.settlement', + 'pum_id', + string='Realisasi' + ) + has_settlement = fields.Boolean( + string='Has Settlement', + compute='_compute_has_settlement' + ) + settlement_name = fields.Char( + string="Nama Realisasi", + compute='_compute_settlement_name' + ) + + grand_total_reimburse = fields.Monetary( + string='Total Reimburse', + compute='_compute_grand_total_reimburse', + currency_field='currency_id' + ) + + is_current_user_ap = fields.Boolean( + string="Is Current User AP", + compute='_compute_is_current_user_ap' + ) + + @api.onchange('grand_total_reimburse', 'type_request') + def _onchange_reimburse_line_update_nominal(self): + if self.type_request == 'reimburse': + self.nominal = self.grand_total_reimburse + + def _compute_is_current_user_ap(self): + ap_user_ids = [23, 9468] + is_ap = self.env.user.id in ap_user_ids + for line in self: + line.is_current_user_ap = is_ap + + @api.depends('reimburse_line_ids.total') + def _compute_grand_total_reimburse(self): + for request in self: + request.grand_total_reimburse = sum(request.reimburse_line_ids.mapped('total')) + + def action_open_create_reimburse_cab(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Buat Jurnal Reimburse', + 'res_model': 'create.reimburse.cab.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_request_id': self.id, + 'default_total_reimburse': self.grand_total_reimburse, + } + } + + @api.depends('settlement_ids') + def _compute_has_settlement(self): + for rec in self: + rec.has_settlement = bool(rec.settlement_ids) + + @api.depends('settlement_ids', 'settlement_ids.name') + def _compute_settlement_name(self): + for request in self: + if request.settlement_ids: + request.settlement_name = request.settlement_ids[0].name + else: + request.settlement_name = False + + @api.onchange('is_represented') + def _onchange_is_represented(self): + if self.is_represented: + self.account_name = False + self.applicant_name = False + else: + self.account_name = self.env.user.id + self.applicant_name = self.env.user.id + + @api.onchange('nominal') + def _onchange_nominal_no_minus(self): + if self.nominal and self.nominal < 0: + self.nominal = 0 + return { + 'warning': { + 'title': _('Nominal Tidak Valid'), + 'message': _( + "Nominal tidak boleh diisi minus.\n" + "Nilai di set menjadi nol." + ) + } + } + + def _get_jasper_attachment(self): + self.ensure_one() + report = self.env['ir.actions.report'].browse(1134) # ID Downpayment Report + if not report: + raise UserError("Report Jasper tidak ditemukan.") + + data = report.render_jasper(self.ids, data={})[0] + filename = f"{self.number}.pdf" + return { + 'name': filename, + 'datas': base64.b64encode(data), + 'type': 'binary', + 'mimetype': 'application/pdf', + 'filename': filename, + } + + # def action_send_pum_reminder(self): + # """ + # Kirim email reminder PUM otomatis. + # - PUM Perjalanan: + # - Hari H kembali ke kantor = template 'mail_template_pum_reminder_today' + # - H-2 dari due date = template 'mail_template_pum_reminder_h_2' + # - PUM Non-Perjalanan: + # - H-2 dari due date = template 'mail_template_pum_reminder_h_2' + # """ + # today = date.today() + + # # Penyesuaian 1: Cari semua PUM yang sudah disetujui (bukan draft/reject) + # # Kita tidak filter 'date_back_to_office' di sini lagi. + # pum_ids = self.search([ + # ('status', 'not in', ['draft', 'reject']), + # ('type_request', '=', 'pum') + # ]) + + # template_today = self.env.ref('indoteknik_custom.mail_template_pum_reminder_today', raise_if_not_found=False) + # template_h2 = self.env.ref('indoteknik_custom.mail_template_pum_reminder_h_2', raise_if_not_found=False) + + # if not template_today or not template_h2: + # _logger.warning("Salah satu template email PUM (today/h2) tidak ditemukan.") + # return + + # for pum in pum_ids: + # _logger.info(f"[REMINDER] Memproses PUM {pum.number}") + + # # Penyesuaian 2: Logika ini sudah benar (sesuai update kita sebelumnya) + # # Jika realisasi sudah dibuat, PUM tidak aktif lagi, lewati. + # realization = self.env['advance.payment.settlement'].search([('pum_id', '=', pum.id)], limit=1) + # if realization: + # _logger.info(f"[REMINDER] Lewati PUM {pum.number}, realisasi sudah dibuat.") + # continue + + # if not pum.email_ap or not pum.user_id.partner_id.email: + # _logger.warning(f"[REMINDER] Lewati PUM {pum.number} karena email_ap atau email user kosong.") + # continue + + # # Penyesuaian 3: Logika penentuan Due Date (Wajib) + # due_date = False + # base_date_for_today_check = False # Khusus PUM Perjalanan + + # if pum.apr_perjalanan: + # if pum.date_back_to_office: + # due_date = pum.date_back_to_office + timedelta(days=7) + # base_date_for_today_check = pum.date_back_to_office + # else: + # _logger.warning(f"[REMINDER] Lewati PUM {pum.number} (perjalanan) karena tgl kembali kosong.") + # continue + # else: + # # Ini adalah PUM Non-Perjalanan + # if not pum.create_date: + # _logger.warning(f"[REMINDER] Lewati PUM {pum.number} (non-perjalanan) karena create_date kosong.") + # continue + # base_date = pum.create_date.date() + # due_date = base_date + timedelta(days=7) + + # # Hitung sisa hari + # days_remaining = (due_date - today).days + + # # Penyesuaian 4: Tentukan template berdasarkan sisa hari + # template = False + # if pum.apr_perjalanan and base_date_for_today_check == today: + # # Hari H kembali ke kantor (HANYA PUM Perjalanan) + # template = template_today + # elif days_remaining == 2: + # # H-2 due date (Untuk SEMUA jenis PUM) + # template = template_h2 + # else: + # _logger.info(f"[REMINDER] Lewati PUM {pum.number}, hari ini bukan tgl pengingat (Sisa hari: {days_remaining}).") + # continue + + # # --- Sisanya (Generate attachment & kirim email) sudah aman --- + + # # Generate attachment + # try: + # attachment_vals = pum._get_jasper_attachment() + # attachment = self.env['ir.attachment'].create({ + # 'name': attachment_vals['name'], + # 'type': 'binary', + # 'datas': attachment_vals['datas'], + # 'res_model': 'advance.payment.request', + # 'res_id': pum.id, + # 'mimetype': 'application/pdf', + # }) + # except Exception as e: + # _logger.error(f"[REMINDER] Gagal membuat attachment untuk PUM {pum.number}: {str(e)}") + # continue + + # email_values = { + # # 'email_to': pum.user_id.partner_id.email, + # 'email_to': 'andrifebriyadiputra@gmail.com', # Masih hardcode + # 'email_from': pum.email_ap, + # 'email_from': 'finance@indoteknik.co.id', + # 'attachment_ids': [(6, 0, [attachment.id])] + # } + + # _logger.info(f"[REMINDER] Mengirim email PUM {pum.number} ke {email_values['email_to']} dari {email_values['email_from']}") + + # try: + # body_html = template._render_field('body_html', [pum.id])[pum.id] + # template.send_mail(pum.id, force_send=True, email_values=email_values) + # _logger.info(f"[REMINDER] Email berhasil dikirim untuk PUM {pum.number}") + + # # Post info sederhana + # pum.message_post( + # body="Email Reminder Berhasil dikirimkan", + # message_type="comment", + # subtype_xmlid="mail.mt_note", + # ) + + # user_system = self.env['res.users'].browse(25) + # system_id = user_system.partner_id.id if user_system else False + + # # Post isi email ke chatter + # pum.message_post( + # body=body_html, + # message_type="comment", + # subtype_xmlid="mail.mt_note", + # author_id=system_id, + # ) + # except Exception as e: + # _logger.error(f"[REMINDER] Gagal mengirim email untuk PUM {pum.number}: {str(e)}") + + # return True + + + @api.depends('move_id.state') + def _compute_is_cab_visible(self): + for rec in self: + move = rec.move_id + rec.is_cab_visible = bool(move and move.state == 'posted') + + def action_view_journal_uangmuka(self): + self.ensure_one() + + ap_user_ids = [23, 9468] + if self.env.user.id not in ap_user_ids: + raise UserError('Hanya User AP yang dapat menggunakan fitur ini.') + + if not self.move_id: + raise UserError("Journal Uang Muka belum tersedia.") + + return { + 'name': 'Journal Entry', + 'view_mode': 'form', + 'res_model': 'account.move', + 'type': 'ir.actions.act_window', + 'res_id': self.move_id.id, + 'target': 'current', + } + + @api.onchange('attachment_type') + def _onchange_attachment_type(self): + self.attachment_file_image = False + self.attachment_filename_image = False + self.attachment_file_pdf = False + self.attachment_filename_pdf = False + + # Sales & MD : Darren ID 19 + # Marketing : Iwan ID 216 + # Logistic & Procurement : Rafly H ID 21 + # FAT & IT : Stephan ID 28 + # HR & GA : Akbar ID 7 / Pimpinan + # --------------------------------------- + # AP : Manzila (Finance) ID 23 + + def _get_approver_mapping(self): + return { + 'sales': 19, + 'merchandiser': 19, + 'marketing': 216, + 'logistic': 21, + 'procurement': 21, + 'fat': 28, + 'it': 28, + 'hr_ga': 7, + } + + def _get_departement_approver(self): + mapping = self._get_approver_mapping() + return mapping.get(self.departement_type) + + @api.constrains('apr_perjalanan', 'date_back_to_office') + def _check_date_back_to_office(self): + if self.apr_perjalanan and not self.date_back_to_office: + raise ValidationError("Tanggal Kembali ke Kantor wajib diisi jika PUM Perjalanan dicentang.") + + @api.onchange('applicant_name') + def _onchange_applicant_name_set_position(self): + if self.applicant_name: + user_id = self.applicant_name.id + mapping = self._get_approver_mapping() + manager_ids = set(mapping.values()) + pimpinan_id = 7 + if user_id == pimpinan_id: + self.position_type = 'pimpinan' + elif user_id in manager_ids: + self.position_type = 'manager' + else: + self.position_type = 'staff' + else: + self.position_type = False + + # @api.model + # def default_get(self, fields_list): + # defaults = super(AdvancePaymentRequest, self).default_get(fields_list) + # user_id = defaults.get('user_id', self.env.uid) + # mapping = self._get_approver_mapping() + # manager_ids = set(mapping.values()) + # pimpinan_id = 7 + + # position = 'staff' + # if user_id == pimpinan_id: + # position = 'pimpinan' + # elif user_id in manager_ids: + # position = 'manager' + + # defaults['position_type'] = position + # return defaults + + def action_realisasi_pum(self): + self.ensure_one() + + realization = self.env['advance.payment.settlement'].search([('pum_id', '=', self.id)], limit=1) + + if realization: + return { + 'type': 'ir.actions.act_window', + 'name': 'Realisasi PUM', + 'res_model': 'advance.payment.settlement', + 'view_mode': 'form', + 'target': 'current', + 'res_id': realization.id, + } + else: + return { + 'type': 'ir.actions.act_window', + 'name': 'Realisasi PUM', + 'res_model': 'advance.payment.settlement', + 'view_mode': 'form', + 'target': 'current', + 'context': { + 'default_pum_id': self.id, + # 'default_value_down_payment': self.nominal, + 'default_name': f'Realisasi - {self.number or ""}', + # 'default_pemberian_line_ids': [ + # (0, 0, { + # 'date': self.create_date.date() if self.create_date else fields.Date.today(), + # 'description': 'Uang Muka', + # 'value': self.nominal + # }) + # ] + } + } + + + def action_confirm_payment(self): + # jakarta_tz = pytz.timezone('Asia/Jakarta') + # now = datetime.now(jakarta_tz).replace(tzinfo=None) + + ap_user_ids = [23, 9468] + if self.env.user.id not in ap_user_ids: + raise UserError('Hanya User AP yang dapat menggunakan fitur ini.') + + for rec in self: + if not rec.attachment_file_image and not rec.attachment_file_pdf: + raise UserError( + f'Tidak bisa konfirmasi pembayaran PUM {rec.name or ""} ' + f'karena belum ada bukti attachment (PDF/Image).' + ) + + rec.status_pay_down_payment = 'payment' + rec.upload_attachment_date = datetime.utcnow() + + rec.message_post( + body="Bukti transfer telah di upload oleh <b>Finance AP</b>.", + message_type="comment", + subtype_xmlid="mail.mt_note", + ) + + + + # def action_approval_check(self): + # for record in self: + # # user = record.user_id + # user = self.env['res.users'].browse(3401) + # roles = sorted(set( + # f"{group + # .name} (Category: {group.category_id.name})" + # for group in user.groups_id + # if group.category_id.name == 'Roles' + # )) + # _logger.info(f"[ROLE CHECK] User: {user.name} (Login: {user.login}) Roles: {roles}") + # return + + def action_approval_check(self): + jakarta_tz = pytz.timezone('Asia/Jakarta') + now = datetime.now(jakarta_tz).replace(tzinfo=None) + formatted_date = now.strftime('%d %B %Y %H:%M') + + for rec in self: + if not rec.departement_type: + raise UserError("Field 'departement_type' wajib diisi sebelum approval.") + + approver_id = rec._get_departement_approver() + + if rec.status == 'pengajuan1': + if self.env.user.id != approver_id: + raise UserError("Hanya approver departement yang berhak menyetujui tahap ini.") + rec.name_approval_departement = self.env.user.name + rec.approved_by = (rec.approved_by + ', ' if rec.approved_by else '') + rec.name_approval_departement + rec.date_approved_department = now + + # Mapping posisi berdasarkan departement_type + department_titles = { + 'sales': 'Sales Manager', + 'merchandiser': 'Merchandiser Manager', + 'marketing': 'Marketing Manager', + 'logistic': 'Logistic Manager', + 'procurement': 'Procurement Manager', + 'fat': 'Finance & Accounting Manager', + 'it': 'IT Manager', + 'hr_ga': 'HR & GA Manager', + 'pimpinan': 'Pimpinan', + } + rec.position_department = department_titles.get(rec.departement_type, 'Departement Manager') + + rec.status = 'pengajuan2' + + rec.message_post( + body=f"Approval <b>Departement</b> oleh <b>{self.env.user.name}</b> " + f"pada <i>{formatted_date}</i>." + ) + + elif rec.status == 'pengajuan2': + ap_user_ids = [23, 9468] # List user ID yang boleh approve sebagai Finance AP + if self.env.user.id not in ap_user_ids: + raise UserError("Hanya AP yang berhak menyetujui tahap ini.") + rec.name_approval_ap = self.env.user.name + rec.approved_by = (rec.approved_by + ', ' if rec.approved_by else '') + rec.name_approval_ap + rec.email_ap = self.env.user.email + rec.date_approved_ap = now + rec.position_ap = 'Finance AP' + if rec.position_type == 'pimpinan': + rec.status = 'approved' + else: + rec.status = 'pengajuan3' + + rec.message_post( + body=f"Approval <b>AP</b> oleh <b>{self.env.user.name}</b> " + f"pada <i>{formatted_date}</i>." + ) + + elif rec.status == 'pengajuan3': + if self.env.user.id != 7: # ID user Pimpinan + raise UserError("Hanya Pimpinan yang berhak menyetujui tahap ini.") + rec.name_approval_pimpinan = self.env.user.name + rec.approved_by = (rec.approved_by + ', ' if rec.approved_by else '') + rec.name_approval_pimpinan + rec.date_approved_pimpinan = now + rec.position_pimpinan = 'Pimpinan' + rec.status = 'approved' + + rec.message_post( + body=f"Approval <b>Pimpinan</b> oleh <b>{self.env.user.name}</b> " + f"pada <i>{formatted_date}</i>." + ) + + else: + raise UserError("Status saat ini tidak bisa di-approve lagi.") + + # rec.message_post(body=f"Approval oleh {self.env.user.name} pada tahap <b>{rec.status}</b>.") + + + + def action_ap_only(self): + self.ensure_one() + + ap_user_ids = [23, 9468] # Ganti sesuai kebutuhan + if self.env.user.id not in ap_user_ids: + raise UserError('Hanya User AP yang dapat menggunakan fitur ini.') + + if self.move_id: + raise UserError('CAB / Jurnal sudah pernah dibuat untuk PUM ini.') + + return { + 'name': 'Create CAB AP Only', + 'type': 'ir.actions.act_window', + 'res_model': 'advance.payment.create.bill', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_nominal': self.nominal, + 'default_apr_id': self.id, + } + } + + + @api.depends('date_back_to_office', 'status', 'apr_perjalanan', 'create_date', 'settlement_ids.status', 'type_request') + def _compute_days_remaining(self): + today = date.today() + for rec in self: + + current_days = rec.days_remaining or 0 + current_due_date = rec.estimated_return_date or False + if rec.type_request == 'pum': + is_settlement_approved = any(s.status == 'approved' for s in rec.settlement_ids) + if not is_settlement_approved: + due_date = False + + if rec.apr_perjalanan: + # Alur PUM Perjalanan + if rec.date_back_to_office: + due_date = rec.date_back_to_office + timedelta(days=7) + effective_today = max(today, rec.date_back_to_office) + + current_due_date = due_date + current_days = (due_date - effective_today).days + else: + + current_due_date = False + current_days = 0 + else: + # Alur PUM Non-Perjalanan + if rec.create_date: + base_date = rec.create_date.date() + due_date = base_date + timedelta(days=7) + + current_due_date = due_date + current_days = (due_date - today).days + else: + current_due_date = False + current_days = 0 + else: + current_due_date = False + current_days = 0 + + rec.days_remaining = current_days + rec.estimated_return_date = current_due_date + + @api.onchange('date_back_to_office') + def _onchange_date_back_to_office(self): + if self.date_back_to_office and self.date_back_to_office < date.today(): + return { + 'warning': { + 'title': _('Tanggal Tidak Valid'), + 'message': _('Tanggal kembali ke kantor tidak boleh lebih awal dari hari ini.') + } + } + + @api.onchange('applicant_name') + def _onchange_applicant_name(self): + if self.applicant_name: + self.account_name = self.applicant_name.id + + @api.onchange('account_name') + def _onchange_account_name(self): + if self.account_name: + self.applicant_name = self.account_name.id + + @api.onchange('user_id') + def _onchange_user_id_limit_check(self): + if self.type_request != 'pum': + return + if not self.user_id: + return + + pum_ids = self.search([ + ('user_id', '=', self.user_id.id), + ('status', '!=', 'reject'), + ('type_request', '=', 'pum') + ]) + + active_pum_count = 0 + for pum in pum_ids: + realization = self.env['advance.payment.settlement'].search([('pum_id', '=', pum.id)], limit=1) + if not realization: + active_pum_count += 1 + + if active_pum_count >= 2: + return { + 'warning': { + 'title': 'Batas Pengajuan Tercapai', + 'message': 'User ini sudah memiliki 2 PUM aktif. Tidak dapat mengajukan lagi sampai salah satu direalisasi.', + } + } + + def _get_department_titles_mapping(self): + return { + 'sales': 'Sales Manager', + 'merchandiser': 'Merchandiser Manager', + 'marketing': 'Marketing Manager', + 'logistic': 'Logistic Manager', + 'procurement': 'Procurement Manager', + 'fat': 'Finance & Accounting Manager', + 'it': 'IT Manager', + 'hr_ga': 'HR & GA Manager', + } + + @api.model + def create(self, vals): + jakarta_tz = pytz.timezone('Asia/Jakarta') + now = datetime.now(jakarta_tz).replace(tzinfo=None) + user = self.env.user + + pum_ids = self.search([ + ('user_id', '=', user.id), + ('status', '!=', 'reject'), + ('type_request', '=', 'pum') + ]) + + active_pum_count = 0 + for pum in pum_ids: + realization = self.env['advance.payment.settlement'].search([('pum_id', '=', pum.id)], limit=1) + if not realization: + active_pum_count += 1 + + if active_pum_count >= 2 and vals.get('type_request') == 'pum': + raise UserError("Anda hanya dapat mengajukan maksimal 2 PUM aktif. Silakan realisasikan salah satunya terlebih dahulu.") + + if not vals.get('apr_perjalanan'): + if 'estimated_return_date' not in vals: + today = date.today() + due_date = today + timedelta(days=7) + vals['estimated_return_date'] = due_date + + initial_status = '' + position = vals.get('position_type') + department = vals.get('departement_type') + if department == 'hr_ga' or position in ('manager', 'pimpinan'): + initial_status = 'pengajuan2' + else: + initial_status = 'pengajuan1' + + vals['status'] = initial_status + + if initial_status == 'pengajuan2' and department != 'hr_ga': + applicant_name = vals.get('applicant_name') + vals['name_approval_departement'] = self.env['res.users'].browse(applicant_name).name or '' + vals['date_approved_department'] = now + department_type = vals.get('departement_type') + department_titles = self._get_department_titles_mapping() + vals['position_department'] = department_titles.get(department_type, 'Departement Manager') + + if position == 'pimpinan' and department != 'hr_ga': + vals['name_approval_pimpinan'] = self.env['res.users'].browse(vals.get('applicant_name')).name or '' + vals['position_pimpinan'] = 'Pimpinan' + vals['date_approved_pimpinan'] = now + # if position == 'staff': + # initial_status = 'pengajuan1' + # elif position == 'manager': + # initial_status = 'pengajuan2' + # applicant_name = vals.get('applicant_name') + # vals['name_approval_departement'] = self.env['res.users'].browse(applicant_name).name or '' + # vals['date_approved_department'] = now + # department_type = vals.get('departement_type') + # department_titles = self._get_department_titles_mapping() + # vals['position_department'] = department_titles.get(department_type, 'Departement Manager') + # elif position == 'pimpinan': + # initial_status = 'pengajuan2' + # applicant_name = vals.get('applicant_name') + # vals['name_approval_pimpinan'] = self.env['res.users'].browse(applicant_name).name or '' + # vals['position_pimpinan'] = 'Pimpinan' + # vals['date_approved_pimpinan'] = now + + # vals['status'] = initial_status + + if not vals.get('number') or vals['number'] == 'New Draft': + if vals.get('type_request') == 'reimburse': + vals['number'] = self.env['ir.sequence'].next_by_code('reimburse.request') or 'New Draft' + else: + vals['number'] = self.env['ir.sequence'].next_by_code('advance.payment.request') or 'New Draft' + + # vals['status'] = 'pengajuan1' + # return super(AdvancePaymentRequest, self).create(vals) + rec = super(AdvancePaymentRequest, self).create(vals) + if rec.type_request == 'reimburse': + rec._compute_grand_total_reimburse() + rec.nominal = rec.grand_total_reimburse + return rec + + +class AdvancePaymentUsageLine(models.Model): + _name = 'advance.payment.usage.line' + _description = 'Advance Payment Usage Line' + + realization_id = fields.Many2one('advance.payment.settlement', string='Realization') + date = fields.Date(string='Tanggal', required=True, default=fields.Date.today) + description = fields.Text(string='Description', required=True) + nominal = fields.Float(string='Nominal', required=True) + done_attachment = fields.Boolean(string='Checked', default=False) + + lot_of_attachment = fields.Selection( + related='realization_id.lot_of_attachment', + string='Lot of Attachment (Related)', + store=False + ) + + attachment_type = fields.Selection([ + ('pdf', 'PDF'), + ('image', 'Image'), + ], string="Attachment Type", default='pdf') + + attachment_file_image = fields.Binary(string='Attachment Image', attachment_filename='attachment_filename_image') + attachment_file_pdf = fields.Binary(string='Attachment PDF', attachment_filename='attachment_filename_pdf') + attachment_filename_image = fields.Char(string='Filename Image') + attachment_filename_pdf = fields.Char(string='Filename PDF') + + account_id = fields.Many2one( + 'account.account', string='Jenis Biaya', tracking=3, + domain="[('id', 'in', [484, 486, 488, 506, 507, 625, 471, 519, 527, 528, 529, 530, 565])]" # ID Jenis Biaya yang dibutuhkan + ) + + is_current_user_ap = fields.Boolean( + string="Is Current User AP", + compute='_compute_is_current_user_ap' + ) + + def _compute_is_current_user_ap(self): + ap_user_ids = [23, 9468] + is_ap = self.env.user.id in ap_user_ids + for line in self: + line.is_current_user_ap = is_ap + + # @api.onchange('account_id') + # def _onchange_account_id(self): + # for rec in self: + # if rec.account_id: + # rec.description = rec.account_id.name + " - " + + @api.onchange('attachment_type') + def _onchange_attachment_type(self): + self.attachment_file_image = False + self.attachment_filename_image = False + self.attachment_file_pdf = False + self.attachment_filename_pdf = False + + @api.onchange('done_attachment') + def _onchange_done_attachment(self): + ap_user_ids = [23, 9468] # List user ID yang boleh approve sebagai Finance AP + + if self.done_attachment and self.env.user.id not in ap_user_ids: + self.done_attachment = False + return { + 'warning': { + 'title': _('Tidak Diizinkan'), + 'message': _('Hanya user AP yang bisa mencentang Done Attachment.') + } + } + + @api.onchange('nominal') + def _onchange_nominal_no_minus(self): + if self.nominal and self.nominal < 0: + self.nominal = 0 + return { + 'warning': { + 'title': _('Nominal Tidak Valid'), + 'message': _( + "Nominal penggunaan PUM tidak boleh diisi minus.\n" + "Nilai di Set menjadi nol." + ) + } + } + +class ReimburseLine(models.Model): + _name = 'reimburse.line' + _description = 'Reimburse Line' + + request_id = fields.Many2one('advance.payment.request', string='Request') + date = fields.Date(string='Tanggal', required=True, default=fields.Date.today) + account_id = fields.Many2one( + 'account.account', + string='Jenis Biaya', tracking=3, + domain="[('id', 'in', [484, 486, 527, 529, 530, 471, 473, 492, 493, 488, 625, 528, 533, 534])]" + ) + description = fields.Text(string='Description', required=True, tracking=3) + distance_departure = fields.Float(string='Pergi (Km)', tracking=3) + distance_return = fields.Float(string='Pulang (Km)', tracking=3) + quantity = fields.Float(string='Quantity', tracking=3, default=1) + price_unit = fields.Float(string='Price', tracking=3) + total = fields.Float(string='Total', tracking=3, compute='_compute_total') + # total = fields.Float(string='Total', tracking=3) + currency_id = fields.Many2one(related='request_id.currency_id') + + is_vehicle = fields.Boolean(string='Berkendara?') + vehicle_type = fields.Selection([ + ('motor', 'Motor'), + ('car', 'Mobil'), + ], string='Tipe Kendaraan', tracking=3) + + attachment_image = fields.Binary(string='Image', attachment_filename='attachment_name_image') + attachment_pdf = fields.Binary(string='PDF', attachment_filename='attachment_name_pdf') + attachment_name_image = fields.Char(string='Filename Image') + attachment_name_pdf = fields.Char(string='Filename PDF') + + attachment_type = fields.Selection([ + ('pdf', 'PDF'), + ('image', 'Image'), + ], string="Attachment Type") + + is_checked = fields.Boolean(string='Checked', default=False) + + is_current_user_ap = fields.Boolean( + string="Is Current User AP", + compute='_compute_is_current_user_ap' + ) + + def _compute_is_current_user_ap(self): + ap_user_ids = [23, 9468] + is_ap = self.env.user.id in ap_user_ids + for line in self: + line.is_current_user_ap = is_ap + + @api.depends('quantity', 'price_unit', 'is_vehicle') + def _compute_total(self): + for line in self: + line.total = line.quantity * line.price_unit + + @api.onchange('is_vehicle', 'vehicle_type', 'distance_departure', 'distance_return') + def _onchange_vehicle_data(self): + if not self.is_vehicle: + self.vehicle_type = False + self.distance_departure = 0 + self.distance_return = 0 + self.price_unit = 0 + return + + total_distance = self.distance_departure + self.distance_return + + if self.vehicle_type and total_distance > 0: + biaya_per_km = 0 + if self.vehicle_type == 'car': + biaya_per_km = 1000 # Rp 10.000 / 10 km + self.price_unit = biaya_per_km + elif self.vehicle_type == 'motor': + biaya_per_km = 500 # Rp 10.000 / 20 km + self.price_unit = biaya_per_km + self.total = total_distance * biaya_per_km + self.quantity = total_distance + else: + self.total = 0 + +class AdvancePaymentSettlement(models.Model): + _name = 'advance.payment.settlement' + _description = 'Advance Payment Settlement' + _inherit = ['mail.thread'] + _rec_name = 'name' + _order = 'create_date desc' + + pum_id = fields.Many2one('advance.payment.request', string='No PUM', ondelete='cascade') + name = fields.Char(string='Nama', readonly=True, tracking=3) + title = fields.Char(string='Judul', tracking=3) + goals = fields.Text(string='Tujuan', tracking=3) + related = fields.Char(string='Terkait', tracking=3) + + # pemberian_line_ids = fields.One2many( + # 'advance.payment.settlement.line', 'realization_id', string='Rincian Pemberian' + # ) + penggunaan_line_ids = fields.One2many( + 'advance.payment.usage.line', 'realization_id', string='Rincian Penggunaan' + ) + + nominal_pum = fields.Float( + string='Nominal Pemberian PUM', + related='pum_id.nominal', + readonly=True ) + + # grand_total = fields.Float(string='Grand Total Pemberian', tracking=3, compute='_compute_grand_total') + grand_total_use = fields.Float(string='Grand Total Penggunaan', tracking=3, compute='_compute_grand_total_use') + # value_down_payment = fields.Float(string='PUM', tracking=3) + remaining_value = fields.Float(string='Sisa Uang PUM', tracking=3, compute='_compute_remaining_value') + + note_approval = fields.Text(string='Note Persetujuan', tracking=3) + + name_approval_departement = fields.Char(string='Approval Departement') + name_approval_ap = fields.Char(string='Approval AP') + name_approval_pimpinan = fields.Char(string='Approval Pimpinan') + + date_approved_department = fields.Datetime(string="Date Approved Department") + date_approved_ap = fields.Datetime(string="Date Approved AP") + date_approved_pimpinan = fields.Datetime(string="Date Approved Pimpinan") + + position_department = fields.Char(string='Position Departement') + position_ap = fields.Char(string='Position AP') + position_pimpinan = fields.Char(string='Position Pimpinan') + + approved_by = fields.Char(string='Approved By', track_visibility='always') + + status = fields.Selection([ + ('pengajuan1', 'Menunggu Approval Departement'), + ('pengajuan2', 'Menunggu Approval AP'), + ('pengajuan3', 'Menunggu Approval Pimpinan'), + ('approved', 'Approved'), + ], string='Status', default='pengajuan1', tracking=3, index=True, track_visibility='onchange') + + # --- DIHAPUS --- + # done_status = fields.Selection([ + # ('remaining', 'Remaining'), + # ('done_not_realized', 'Done Not Realized'), + # ('done_realized', 'Done Realized') + # ], string='Status Realisasi', tracking=3, default='remaining') + # date_done_not_realized = fields.Date(string='Tanggal Done Not Realized', tracking=3) + # --- BATAS DIHAPUS --- + + currency_id = fields.Many2one( + 'res.currency', string='Currency', + default=lambda self: self.env.company.currency_id + ) + + attachment_file_image = fields.Binary(string='Attachment Image', attachment_filename='attachment_filename_image') + attachment_file_pdf = fields.Binary(string='Attachment PDF', attachment_filename='attachment_filename_pdf') + attachment_filename_image = fields.Char(string='Filename Image') + attachment_filename_pdf = fields.Char(string='Filename PDF') + + attachment_type = fields.Selection([ + ('pdf', 'PDF'), + ('image', 'Image'), + ], string="Attachment Type", default='pdf') + + lot_of_attachment = fields.Selection([ + ('one_for_all_line', '1 Attachment Untuk Semua Line Penggunaan PUM'), + ('one_for_one_line', '1 Attachment per 1 Line Penggunaan PUM'), + ], string = "Banyaknya Attachment", default='one_for_one_line') + + move_id = fields.Many2one('account.move', string='Journal Entries', domain=[('move_type', '=', 'entry')]) + is_cab_visible = fields.Boolean(string='Is Journal Uang Muka Visible', compute='_compute_is_cab_visible') + + user_id = fields.Many2one( + 'res.users', + string='Diajukan Oleh', + related='pum_id.user_id', + readonly=True + ) + applicant_name = fields.Many2one( + 'res.users', + string='Nama Pemohon', + related='pum_id.applicant_name', + readonly=True + ) + is_current_user_ap = fields.Boolean( + string="Is Current User AP", + compute='_compute_is_current_user_ap' + ) + + def _compute_is_current_user_ap(self): + ap_user_ids = [23, 9468] + is_ap = self.env.user.id in ap_user_ids + for line in self: + line.is_current_user_ap = is_ap + + def action_toggle_check_attachment(self): + ap_user_ids = [23, 9468] + if self.env.user.id not in ap_user_ids: + raise UserError('Hanya User AP yang dapat menggunakan tombol ini.') + + for rec in self: + if not rec.penggunaan_line_ids: + continue + + if all(line.done_attachment for line in rec.penggunaan_line_ids): + for line in rec.penggunaan_line_ids: + line.done_attachment = False + else: + for line in rec.penggunaan_line_ids: + line.done_attachment = True + + @api.onchange('lot_of_attachment') + def _onchange_lot_of_attachment(self): + if self.lot_of_attachment == 'one_for_all_line': + for line in self.penggunaan_line_ids: + line.attachment_file_pdf = False + line.attachment_file_image = False + line.attachment_filename_pdf = False + line.attachment_filename_image = False + + + @api.depends('move_id.state') + def _compute_is_cab_visible(self): + for rec in self: + move = rec.move_id + rec.is_cab_visible = bool(move and move.state == 'posted') + + def action_view_journal_uangmuka(self): + self.ensure_one() + + ap_user_ids = [23, 9468] + if self.env.user.id not in ap_user_ids: + raise UserError('Hanya User AP yang dapat menggunakan fitur ini.') + + if not self.move_id: + raise UserError("Journal Uang Muka belum tersedia.") + + return { + 'name': 'Journal Entry', + 'view_mode': 'form', + 'res_model': 'account.move', + 'type': 'ir.actions.act_window', + 'res_id': self.move_id.id, + 'target': 'current', + } + + + @api.onchange('attachment_type') + def _onchange_attachment_type(self): + self.attachment_file_image = False + self.attachment_filename_image = False + self.attachment_file_pdf = False + self.attachment_filename_pdf = False + + # @api.depends('pemberian_line_ids.value') + # def _compute_grand_total(self): + # for rec in self: + # rec.grand_total = sum(line.value for line in rec.pemberian_line_ids) + + @api.depends('penggunaan_line_ids.nominal') + def _compute_grand_total_use(self): + for rec in self: + rec.grand_total_use = sum(line.nominal for line in rec.penggunaan_line_ids) + + @api.depends('nominal_pum', 'grand_total_use') + def _compute_remaining_value(self): + for rec in self: + rec.remaining_value = rec.nominal_pum - rec.grand_total_use + return + + # --- DIHAPUS --- + # def action_validation(self): + # self.ensure_one() + + # # Validasi hanya AP yang bisa validasi + # ap_user_ids = [23, 9468] # List user ID yang boleh approve sebagai Finance AP + # if self.env.user.id not in ap_user_ids: + # raise UserError('Hanya AP yang dapat melakukan validasi realisasi.') + + # if self.done_status == 'remaining': + # self.done_status = 'done_not_realized' + # self.date_done_not_realized = fields.Date.today() + # elif self.done_status == 'done_not_realized': + # self.done_status = 'done_realized' + # else: + # raise UserError('Realisasi sudah berstatus Done Realized.') + + # # Opsional: Tambah log di chatter + # self.message_post(body=f"Status realisasi diperbarui menjadi <b>{dict(self._fields['done_status'].selection).get(self.done_status)}</b> oleh {self.env.user.name}.") + # --- BATAS DIHAPUS --- + + def action_cab(self): + self.ensure_one() + + ap_user_ids = [23, 9468] # List user ID yang boleh approve sebagai Finance AP + if self.env.user.id not in ap_user_ids: + raise UserError('Hanya User AP yang dapat menggunakan ini.') + if self.move_id: + raise UserError("CAB / Jurnal sudah pernah dibuat untuk Realisasi ini.") + + if not self.pum_id or not self.pum_id.move_id: + raise UserError("PUM terkait atau CAB belum tersedia.") + + partner_id = self.pum_id.user_id.partner_id.id + cab_move = self.pum_id.move_id + + # Account Bank Intransit dari CAB: + bank_intransit_line = cab_move.line_ids.filtered(lambda l: l.account_id.id in [573, 389, 392]) + if not bank_intransit_line: + raise UserError("Account Bank Intransit dengan tidak ditemukan di CAB terkait.") + account_sisa_pum = bank_intransit_line[0].account_id.id + + # Account Uang Muka Operasional + account_uang_muka = 403 + + # --- PENYESUAIAN LOGIKA --- + # Tanggal pakai create_date atau hari ini + account_date = fields.Date.context_today(self) + # --- BATAS PENYESUAIAN --- + + ref_label = f"Realisasi {self.pum_id.number} {self.pum_id.detail_note} ({cab_move.name})" + + label_sisa_pum = f"Sisa PUM {self.pum_id.detail_note} {self.pum_id.number} ({cab_move.name})" + + lines = [] + + # Sisa PUM (Debit) + if self.remaining_value > 0: + lines.append((0, 0, { + 'account_id': account_sisa_pum, + 'partner_id': partner_id, + 'name': label_sisa_pum, + 'debit': self.remaining_value, + 'credit': 0, + })) + + # Biaya Penggunaan (Debit) + total_biaya = 0 + for line in self.penggunaan_line_ids: + lines.append((0, 0, { + 'account_id': line.account_id.id, + 'partner_id': partner_id, + 'name': f"{line.description} ({line.date})", + 'debit': line.nominal, + 'credit': 0, + })) + total_biaya += line.nominal + + # Uang Muka Operasional (Credit) + total_credit = self.remaining_value + total_biaya + if total_credit > 0: + lines.append((0, 0, { + 'account_id': account_uang_muka, + 'partner_id': partner_id, + 'name': ref_label, + 'debit': 0, + 'credit': total_credit, + })) + + move = self.env['account.move'].create({ + 'ref': ref_label, + 'date': account_date, + 'journal_id': 11, # MISC + 'line_ids': lines, + }) + + # self.message_post(body=f"Jurnal CAB telah dibuat dengan nomor: <b>{move.name}</b>.") + + self.move_id = move.id + + return { + 'name': _('Journal Entry'), + 'view_mode': 'form', + 'res_model': 'account.move', + 'type': 'ir.actions.act_window', + 'res_id': move.id, + 'target': 'current', + } + + def action_approval_check(self): + jakarta_tz = pytz.timezone('Asia/Jakarta') + now = datetime.now(jakarta_tz).replace(tzinfo=None) + formatted_date = now.strftime('%d %B %Y %H:%M') + + for rec in self: + if not rec.pum_id.departement_type: + raise UserError("Field 'departement_type' wajib diisi sebelum approval.") + + approver_id = rec.pum_id._get_departement_approver() + + if rec.status == 'pengajuan1': + if self.env.user.id != approver_id: + raise UserError("Hanya approver departement yang berhak menyetujui tahap ini.") + rec.name_approval_departement = self.env.user.name + rec.approved_by = (rec.approved_by + ', ' if rec.approved_by else '') + rec.name_approval_departement + rec.date_approved_department = now + + # Mapping posisi berdasarkan departement_type + department_titles = { + 'sales': 'Sales Manager', + 'merchandiser': 'Merchandiser Manager', + 'marketing': 'Marketing Manager', + 'logistic': 'Logistic Manager', + 'procurement': 'Procurement Manager', + 'fat': 'Finance & Accounting Manager', + 'it': 'IT Manager', + 'hr_ga': 'HR & GA Manager', + 'pimpinan': 'Pimpinan', + } + rec.position_department = department_titles.get(rec.pum_id.departement_type, 'Departement Manager') + + rec.status = 'pengajuan2' + + rec.message_post( + body=f"Approval <b>Departement</b> oleh <b>{self.env.user.name}</b> " + f"pada <i>{formatted_date}</i>." + ) + + elif rec.status == 'pengajuan2': + ap_user_ids = [23, 9468] # List user ID yang boleh approve sebagai Finance AP + if self.env.user.id not in ap_user_ids: + raise UserError("Hanya AP yang berhak menyetujui tahap ini.") + rec.name_approval_ap = self.env.user.name + rec.approved_by = (rec.approved_by + ', ' if rec.approved_by else '') + rec.name_approval_ap + rec.date_approved_ap = now + rec.position_ap = 'Finance AP' + rec.status = 'pengajuan3' + + rec.message_post( + body=f"Approval <b>AP</b> oleh <b>{self.env.user.name}</b> " + f"pada <i>{formatted_date}</i>." + ) + + elif rec.status == 'pengajuan3': + if self.env.user.id != 7: # ID user Pimpinan + raise UserError("Hanya Pimpinan yang berhak menyetujui tahap ini.") + rec.name_approval_pimpinan = self.env.user.name + rec.approved_by = (rec.approved_by + ', ' if rec.approved_by else '') + rec.name_approval_pimpinan + rec.date_approved_pimpinan = now + rec.position_pimpinan = 'Pimpinan' + rec.status = 'approved' + # --- DIHAPUS --- + # rec.done_status = 'done_not_realized' # Set status done untuk realisasi + # --- BATAS DIHAPUS --- + + rec.message_post( + body=f"Approval <b>Pimpinan</b> oleh <b>{self.env.user.name}</b> " + f"pada <i>{formatted_date}</i>." + ) + + else: + raise UserError("Status saat ini tidak bisa di-approve lagi.") + + # rec.message_post(body=f"Approval oleh {self.env.user.name} pada tahap <b>{rec.status}</b>.") + + def _check_remaining_value(self): + for rec in self: + # Cek sisa PUM + if rec.remaining_value < 0: + raise ValidationError( + "Sisa uang PUM tidak boleh kurang dari Rp 0.\n" + "Jika ada penggunaan uang pribadi, maka ajukan dengan sistem reimburse." + ) + + @api.model + def create(self, vals): + jakarta_tz = pytz.timezone('Asia/Jakarta') + # --- PENYESUAIAN LOGIKA WAKTU --- + now = datetime.now(jakarta_tz).replace(tzinfo=None) + # Gunakan fields.Datetime.now() agar konsisten + # now = fields.Datetime.now() + # --- BATAS PENYESUAIAN --- + + pum_id = vals.get('pum_id') + initial_status = '' + if pum_id: + pum_request = self.env['advance.payment.request'].browse(pum_id) + if pum_request: + position_dari_pum = pum_request.position_type + department_dari_pum = pum_request.departement_type + if position_dari_pum == 'staff': + if department_dari_pum == 'hr_ga': + initial_status = 'pengajuan2' + else: + initial_status = 'pengajuan1' + elif position_dari_pum == 'manager': + initial_status = 'pengajuan2' + applicant_name_str = pum_request.applicant_name.name or '' + department_type = pum_request.departement_type + department_titles = pum_request._get_department_titles_mapping() + dept_position = department_titles.get(department_type, 'Departement Manager') + vals['date_approved_department'] = now + vals['name_approval_departement'] = applicant_name_str + vals['position_department'] = dept_position + elif position_dari_pum == 'pimpinan': + initial_status = 'pengajuan2' + applicant_name_str = pum_request.applicant_name.name or '' + vals['date_approved_pimpinan'] = now + vals['name_approval_pimpinan'] = applicant_name_str + vals['position_pimpinan'] = 'Pimpinan' + + # --- PENYESUAIAN LOGIKA: SET DEFAULT JIKA KOSONG --- + vals['status'] = initial_status or 'pengajuan1' + # --- BATAS PENYESUAIAN --- + + rec = super().create(vals) + rec._check_remaining_value() + return rec + + def write(self, vals): + res = super().write(vals) + self._check_remaining_value() + return res + + +class AdvancePaymentCreateBill(models.TransientModel): + _name = 'advance.payment.create.bill' + _description = 'Create Bill from Advance Payment' + + apr_id = fields.Many2one('advance.payment.request', string='Advance Payment Request', required=True) + account_id = fields.Many2one( + 'account.account', string='Bank Intransit', required=True, + domain="[('id', 'in', [573, 389, 392])]" # ID Bank Intransit + ) + nominal = fields.Float(string='Nominal', related='apr_id.nominal') + + def action_create_cab(self): + self.ensure_one() + + # if self.env.user.id != 23: + # raise UserError('Hanya AP yang dapat menggunakan ini.') + + apr = self.apr_id + partner_id = apr.user_id.partner_id.id + + ref_label = f'{apr.number} - {apr.detail_note or "-"}' + + move = self.env['account.move'].create({ + 'ref': ref_label, + 'date': fields.Date.context_today(self), + 'journal_id': 11, # Cash & Bank + 'line_ids': [ + (0, 0, { + 'account_id': 403, # Uang Muka Operasional + 'partner_id': partner_id, + 'name': ref_label, + 'debit': apr.nominal, + 'credit': 0, + }), + (0, 0, { + 'account_id': self.account_id.id, # Bank Intransit yang dipilih + 'partner_id': partner_id, + 'name': ref_label, + 'debit': 0, + 'credit': apr.nominal, + }) + ] + }) + + apr.move_id = move.id # jika ada field untuk menampung move_id + + return { + 'name': _('Journal Entry'), + 'view_mode': 'form', + 'res_model': 'account.move', + 'type': 'ir.actions.act_window', + 'res_id': move.id, + 'target': 'current', + } + +class CreateReimburseCabWizard(models.TransientModel): + _name = 'create.reimburse.cab.wizard' + _description = 'Wizard untuk Membuat Jurnal Reimburse' + + # Field untuk menampung ID request yang sedang diproses + request_id = fields.Many2one('advance.payment.request', string='Pengajuan', readonly=True) + + # Field untuk memilih salah satu dari dua bank Anda + account_id = fields.Many2one( + 'account.account', + string='Bank Intransit (Credit)', + required=True, + # Domain untuk membatasi pilihan hanya pada ID 573 dan 389 + domain="[('id', 'in', [573, 389])]" + ) + + # Field untuk menampilkan total agar pengguna bisa konfirmasi + total_reimburse = fields.Monetary( + string='Total Reimburse', + related='request_id.grand_total_reimburse', + ) + currency_id = fields.Many2one(related='request_id.currency_id', readonly=True) + + def action_create_reimburse_cab(self): + """Metode ini yang akan membuat Journal Entry (CAB).""" + self.ensure_one() + request = self.request_id + + # --- Validasi --- + if request.move_id: + raise UserError("Jurnal sudah pernah dibuat untuk pengajuan ini.") + if not request.reimburse_line_ids: + raise UserError("Tidak ada rincian reimburse yang bisa dijurnalkan.") + + lines = [] + partner_id = request.user_id.partner_id.id + + # 1. Buat Jurnal DEBIT dari setiap baris reimburse + for line in request.reimburse_line_ids: + if not line.account_id: + raise UserError(f"Jenis Biaya pada baris '{line.description}' belum diisi oleh AP.") + + lines.append((0, 0, { + 'account_id': line.account_id.id, + 'partner_id': partner_id, + 'name': line.description, + 'debit': line.total, + 'credit': 0, + })) + + # 2. Buat satu Jurnal CREDIT ke bank yang dipilih di wizard + lines.append((0, 0, { + 'account_id': self.account_id.id, + 'partner_id': partner_id, + 'name': f'Reimburse {request.number}', + 'debit': 0, + 'credit': request.grand_total_reimburse, + })) + + ref_label = f'{request.number} - {request.detail_note or "-"}' + + # 3. Buat Journal Entry + move = self.env['account.move'].create({ + 'ref': ref_label, + 'date': fields.Date.context_today(self), + 'journal_id': 11, # PENTING: Ganti 11 dengan ID Journal "Miscellaneous" Anda + 'line_ids': lines, + }) + + # 4. Tautkan journal yang baru dibuat ke request + request.move_id = move.id + + # 5. Buka tampilan form journal yang baru dibuat + return { + 'name': _('Journal Entry'), + 'view_mode': 'form', + 'res_model': 'account.move', + 'type': 'ir.actions.act_window', + 'res_id': move.id, + 'target': 'current', + }
\ No newline at end of file diff --git a/indoteknik_custom/models/partial_delivery.py b/indoteknik_custom/models/partial_delivery.py index 4df7da1e..519f505c 100644 --- a/indoteknik_custom/models/partial_delivery.py +++ b/indoteknik_custom/models/partial_delivery.py @@ -115,9 +115,13 @@ class PartialDeliveryWizard(models.TransientModel): raise UserError(_("Picking harus dalam status Ready (assigned).")) lines_by_qty = self.line_ids.filtered(lambda l: l.selected_qty > 0) + lines_validation = self.line_ids.filtered(lambda l: l.selected_qty > l.reserved_qty) lines_by_selected = self.line_ids.filtered(lambda l: l.selected and not l.selected_qty) selected_lines = lines_by_qty | lines_by_selected # gabung dua domain hasil filter + if lines_validation: + raise UserError(_("Jumlah yang dipilih melebihi jumlah yang terdapat di DO.")) + if not selected_lines: raise UserError(_("Tidak ada produk yang dipilih atau diisi jumlahnya.")) @@ -172,9 +176,11 @@ class PartialDeliveryWizard(models.TransientModel): for line in selected_lines: if line.selected_qty > line.reserved_qty: raise UserError(_("Jumlah produk %s yang dipilih melebihi jumlah reserved.") % line.product_id.display_name) + move = line.move_id move._do_unreserve() + # 🔹 Kalau cuma selected tanpa qty → anggap kirim semua reserved qty if line.selected and not line.selected_qty: line.selected_qty = line.reserved_qty @@ -186,12 +192,20 @@ class PartialDeliveryWizard(models.TransientModel): if line.selected_qty < move.product_uom_qty: qty_to_keep = move.product_uom_qty - line.selected_qty + new_move = move.copy(default={ 'product_uom_qty': line.selected_qty, 'picking_id': new_picking.id, 'partial': True, }) + + if move.move_dest_ids: + for dest_move in move.move_dest_ids: + # dest_move.write({'move_orig_ids': [(4, new_move.id)]}) + new_move.write({'move_dest_ids': [(4, dest_move.id)]}) + move.write({'product_uom_qty': qty_to_keep}) + else: move.write({'picking_id': new_picking.id, 'partial': True}) diff --git a/indoteknik_custom/models/purchase_order.py b/indoteknik_custom/models/purchase_order.py index e79417aa..534d8122 100755 --- a/indoteknik_custom/models/purchase_order.py +++ b/indoteknik_custom/models/purchase_order.py @@ -1043,7 +1043,7 @@ class PurchaseOrder(models.Model): # test = line.product_uom_qty # test2 = line.product_id.plafon_qty # test3 = test2 + line.product_uom_qty - if line.product_uom_qty > line.product_id.plafon_qty + line.product_uom_qty and not self.env.user.id == 21: + if line.product_uom_qty > line.product_id.plafon_qty + line.product_uom_qty and self.env.user.id not in [21, 7]: raise UserError('Product '+line.product_id.name+' melebihi plafon, harus Approval Rafly') def check_different_vendor_so_po(self): @@ -1123,6 +1123,8 @@ class PurchaseOrder(models.Model): if not self.not_update_purchasepricelist: self.add_product_to_pricelist() for line in self.order_line: + if not line.product_id.public_categ_ids: + raise UserError("Product %s kategorinya kosong" % line.product_id.name) if not line.product_id.purchase_ok: raise UserError("Terdapat barang yang tidak bisa diproses") # Validasi pajak diff --git a/indoteknik_custom/models/sale_order.py b/indoteknik_custom/models/sale_order.py index 5c8f34c6..494aeaa2 100755 --- a/indoteknik_custom/models/sale_order.py +++ b/indoteknik_custom/models/sale_order.py @@ -397,23 +397,24 @@ class SaleOrder(models.Model): string="Partner Locked CBD", compute="_compute_partner_is_cbd_locked" ) + internal_notes_contact = fields.Text(related='partner_id.comment', string="Internal Notes", readonly=True, help="Internal Notes dari contact utama customer.") def action_open_partial_delivery_wizard(self): - raise UserError("Fitur ini sedang dalam pengembangan") - # self.ensure_one() - # pickings = self.picking_ids.filtered(lambda p: p.state not in ['done', 'cancel'] and p.name and 'BU/PICK/' in p.name) - # return { - # 'type': 'ir.actions.act_window', - # 'name': 'Partial Delivery', - # 'res_model': 'partial.delivery.wizard', - # 'view_mode': 'form', - # 'target': 'new', - # 'context': { - # 'default_sale_id': self.id, - # # kasih langsung list of int biar ga ribet di wizard - # 'default_picking_ids': pickings.ids, - # } - # } + # raise UserError("Fitur ini sedang dalam pengembangan") + self.ensure_one() + pickings = self.picking_ids.filtered(lambda p: p.state not in ['done', 'cancel'] and p.name and 'BU/PICK/' in p.name) + return { + 'type': 'ir.actions.act_window', + 'name': 'Partial Delivery', + 'res_model': 'partial.delivery.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'default_sale_id': self.id, + # kasih langsung list of int biar ga ribet di wizard + 'default_picking_ids': pickings.ids, + } + } @api.depends('partner_id.is_cbd_locked') @@ -422,7 +423,7 @@ class SaleOrder(models.Model): order.partner_is_cbd_locked = order.partner_id.is_cbd_locked - @api.constrains('payment_term_id', 'partner_id', 'state') + @api.constrains('payment_term_id', 'partner_id') def _check_cbd_lock_sale_order(self): cbd_term = self.env['account.payment.term'].browse(26) for rec in self: @@ -1985,10 +1986,10 @@ class SaleOrder(models.Model): # raise UserError('Kelurahan Real Delivery Address harus diisi') def generate_payment_link_midtrans_sales_order(self): - midtrans_url = 'https://app.sandbox.midtrans.com/snap/v1/transactions' # dev - sandbox - midtrans_auth = 'Basic U0ItTWlkLXNlcnZlci1uLVY3ZDJjMlpCMFNWRUQyOU95Q1dWWXA6' # dev - sandbox - # midtrans_url = 'https://app.midtrans.com/snap/v1/transactions' # production - # midtrans_auth = 'Basic TWlkLXNlcnZlci1SbGMxZ2gzWGpSVW5scl9JblZzTV9OTnU6' # production + # midtrans_url = 'https://app.sandbox.midtrans.com/snap/v1/transactions' # dev - sandbox + # midtrans_auth = 'Basic U0ItTWlkLXNlcnZlci1uLVY3ZDJjMlpCMFNWRUQyOU95Q1dWWXA6' # dev - sandbox + midtrans_url = 'https://app.midtrans.com/snap/v1/transactions' # production + midtrans_auth = 'Basic TWlkLXNlcnZlci1SbGMxZ2gzWGpSVW5scl9JblZzTV9OTnU6' # production so_number = self.name so_number = so_number.replace('/', '-') @@ -2001,8 +2002,8 @@ class SaleOrder(models.Model): } # ==== ENV ==== - check_url = f'https://api.sandbox.midtrans.com/v2/{so_number}/status' # dev - sandbox - # check_url = f'https://api.midtrans.com/v2/{so_number}/status' # production + # check_url = f'https://api.sandbox.midtrans.com/v2/{so_number}/status' # dev - sandbox + check_url = f'https://api.midtrans.com/v2/{so_number}/status' # production # ============================================= check_response = requests.get(check_url, headers=headers) @@ -2716,7 +2717,7 @@ class SaleOrder(models.Model): return True if user.id in (3401, 20, 3988, 17340): # admin (fida, nabila, ninda) - return True + raise UserError("Yahaha gabisa confirm so, minta ke sales nya ajah") if self.env.context.get("ask_approval") and user.id in (3401, 20, 3988): return True diff --git a/indoteknik_custom/models/solr/apache_solr_queue.py b/indoteknik_custom/models/solr/apache_solr_queue.py index 1b51538f..3d6bd733 100644 --- a/indoteknik_custom/models/solr/apache_solr_queue.py +++ b/indoteknik_custom/models/solr/apache_solr_queue.py @@ -1,10 +1,10 @@ from odoo import models, fields from datetime import datetime, timedelta -import logging, time - +import logging, time, traceback # <-- tambah traceback _logger = logging.getLogger(__name__) + class ApacheSolrQueue(models.Model): _name = 'apache.solr.queue' @@ -19,6 +19,7 @@ class ApacheSolrQueue(models.Model): ], 'Execute Status') execute_date = fields.Datetime('Execute Date') description = fields.Text('Description') + log = fields.Text('Log') def _compute_display_name(self): for rec in self: @@ -39,7 +40,7 @@ class ApacheSolrQueue(models.Model): if elapsed_time > max_exec_time: break rec.execute_queue() - + def open_target_record(self): return { 'name': '', @@ -67,17 +68,21 @@ class ApacheSolrQueue(models.Model): if model_instance: getattr(model_instance, function_name)() rec.execute_status = 'success' + rec.log = traceback.format_exc() else: rec.execute_status = 'not_found' except Exception as e: - rec.description = e + # simpan error ringkas + traceback lengkap + rec.description = str(e) + rec.log = traceback.format_exc() rec.execute_status = 'failed' + rec.execute_date = datetime.utcnow() self.env.cr.commit() def create_unique(self, payload={}): count = self.search_count([ - ('res_model', '=', payload['res_model']), + ('res_model', '=', payload['res_model']), ('res_id', '=', payload['res_id']), ('function_name', '=', payload['function_name']), ('execute_status', '=', False) @@ -90,8 +95,6 @@ class ApacheSolrQueue(models.Model): ('execute_status', '=', 'success'), ('execute_date', '>=', (datetime.utcnow() - timedelta(days=days_after))), ], limit=limit) - + for rec in solr: rec.unlink() - - diff --git a/indoteknik_custom/models/solr/product_product.py b/indoteknik_custom/models/solr/product_product.py index d8bc3973..7260c3ca 100644 --- a/indoteknik_custom/models/solr/product_product.py +++ b/indoteknik_custom/models/solr/product_product.py @@ -69,9 +69,9 @@ class ProductProduct(models.Model): 'product_id_i': variant.id, 'template_id_i': variant.product_tmpl_id.id, 'image_s': ir_attachment.api_image('product.template', 'image_512', variant.product_tmpl_id.id), - 'image_carousel_s': [ir_attachment.api_image('image.carousel', 'image', carousel.id) for carousel in variant.product_tmpl_id.image_carousel_lines], + 'image_carousel_ss': [ir_attachment.api_image('image.carousel', 'image', carousel.id) for carousel in variant.product_tmpl_id.image_carousel_lines], 'image_mobile_s': ir_attachment.api_image('product.template', 'image_256', variant.product_tmpl_id.id), - 'stock_total_f': variant.qty_stock_vendor, + 'stock_total_f': variant.qty_free_bandengan, 'weight_f': variant.weight, 'manufacture_id_i': variant.product_tmpl_id.x_manufacture.id or 0, 'manufacture_name_s': variant.product_tmpl_id.x_manufacture.x_name or '', diff --git a/indoteknik_custom/models/solr/product_template.py b/indoteknik_custom/models/solr/product_template.py index c4aefe19..a7beca12 100644 --- a/indoteknik_custom/models/solr/product_template.py +++ b/indoteknik_custom/models/solr/product_template.py @@ -59,9 +59,9 @@ class ProductTemplate(models.Model): solr_model = self.env['apache.solr'] for template in self: + voucher = None if template.x_manufacture: voucher = self.get_voucher_pastihemat(template.x_manufacture.id) - # Lakukan sesuatu dengan voucher variant_names = ', '.join([x.display_name or '' for x in template.product_variant_ids]) variant_codes = ', '.join([x.default_code or '' for x in template.product_variant_ids]) diff --git a/indoteknik_custom/models/sourcing_job_order.py b/indoteknik_custom/models/sourcing_job_order.py index 3d4a404e..98f356de 100644 --- a/indoteknik_custom/models/sourcing_job_order.py +++ b/indoteknik_custom/models/sourcing_job_order.py @@ -59,13 +59,20 @@ class SourcingJobOrder(models.Model): line_sales_view_ids = fields.One2many( 'sourcing.job.order.line', 'order_id', string='Sales View Lines', - domain=[('price', '>', 0)] + domain=[('state', 'in', ['sourcing', 'done', 'cancel'])] + ) + line_sales_view_cancel_ids = fields.One2many( + 'sourcing.job.order.line', 'order_id', + string='Sales View Lines', + domain=[('state', '=', 'cancel')] ) has_price_in_lines = fields.Boolean( string='Has Line with Price', compute='_compute_has_price_in_lines', ) + is_creator_same_user = fields.Boolean(compute='_compute_is_creator_same_user') + can_convert_to_product = fields.Boolean(string="Can Convert", compute="_compute_can_convert_to_product") @api.depends('line_ids.subtotal') @@ -99,6 +106,21 @@ class SourcingJobOrder(models.Model): rec.user_id == current_user and bool(rec.takeover_request) ) + @api.depends('create_uid', 'user_id') + def _compute_is_creator_same_user(self): + for rec in self: + current_user = self.env.user + rec.is_creator_same_user = ( + rec.create_uid == current_user and + rec.user_id == current_user + ) + + @api.depends('line_md_edit_ids.state') + def _compute_can_convert_to_product(self): + """Cek apakah ada line dengan state 'done'.""" + for rec in self: + rec.can_convert_to_product = any(line.state == 'done' for line in rec.line_md_edit_ids) + @api.model def create(self, vals): """Hanya Sales & Merchandiser yang boleh membuat job.""" @@ -117,22 +139,112 @@ class SourcingJobOrder(models.Model): return rec def write(self, vals): - context_action = self.env.context.get('from_action_take', False) - - if not (self.env.user.has_group('indoteknik_custom.group_role_sales') or - self.env.user.has_group('indoteknik_custom.group_role_merchandiser')): + bypass_actions = ( + self.env.context.get('from_action_take', False) + or self.env.context.get('from_multi_action_take', False) + or self.env.context.get('from_action_takeover', False) + ) + + if not ( + self.env.user.has_group('indoteknik_custom.group_role_sales') + or self.env.user.has_group('indoteknik_custom.group_role_merchandiser') + ): raise UserError("❌ Hanya Sales dan Merchandiser yang boleh mengedit Sourcing Job.") for rec in self: - if not rec.user_id and rec.create_uid != self.env.user and not vals.get('user_id') and not context_action: + if ( + not rec.user_id + and rec.create_uid != self.env.user + and not vals.get('user_id') + and not bypass_actions + ): raise UserError("❌ SJO ini belum memiliki MD Person. Tidak dapat melakukan edit.") - if rec.user_id != self.env.user and rec.create_uid != self.env.user and not context_action: + if ( + rec.user_id != self.env.user + and rec.create_uid != self.env.user + and not bypass_actions + ): raise UserError("❌ Hanya MD Person dan Creator SJO ini yang bisa melakukan Edit.") - - return super().write(vals) + + # --- Simpan data lama sebelum write (buat pembanding) + old_data = {} + for rec in self: + old_data[rec.id] = { + 'state': rec.state, + 'user_id': rec.user_id.id if rec.user_id else False, + 'approval_sales': rec.approval_sales, + 'line_data': { + line.id: { + 'state': line.state, + 'vendor_id': line.vendor_id.id if line.vendor_id else False, + 'price': line.price, + } + for line in rec.line_md_edit_ids + }, + } + + res = super().write(vals) + + # --- 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: <b>{old.get('state')}</b> → <b>{rec.state}</b>") + if old.get('user_id') != (rec.user_id.id if rec.user_id else False): + changes.append(f"MD Person: <b>{old.get('user_id')}</b> → <b>{rec.user_id.name if rec.user_id else '-'}</b>") + if old.get('approval_sales') != rec.approval_sales: + changes.append(f"Approval Status: <b>{old.get('approval_sales')}</b> → <b>{rec.approval_sales}</b>") + + # === Perubahan di line === + old_lines = old.get('line_data', {}) + for line in rec.line_md_edit_ids: + old_line = old_lines.get(line.id) + if not old_line: + continue # line baru, skip validasi perubahan + + # 💥 Validasi vendor berubah tapi price tidak berubah + 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." + ) + + # Catat perubahan state, vendor, atau harga untuk log note + sub_changes = [] + + if old_line['state'] != line.state: + sub_changes.append(f"- state: <b>{old_line['state']}</b> → <b>{line.state}</b>") + + 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: <b>{old_vendor}</b> → <b>{line.vendor_id.name if line.vendor_id else '-'}</b>") + + if old_line['price'] != line.price: + sub_changes.append(f"- price: <b>{old_line['price']}</b> → <b>{line.price}</b>") + + if sub_changes: + joined = "<br/>".join(sub_changes) + changes.append(f"<b>{line.product_name}</b>:<br/>{joined}") + + # Post ke chatter + if changes: + message = "<br/><br/>".join(changes) + rec.message_post( + body=f"Perubahan pada Sourcing Job:<br/>{message}", + subtype_xmlid="mail.mt_comment", + ) + + return res + def action_take(self): + context_action = self.env.context.get('from_action_take', False) for rec in self: if not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'): raise UserError("❌ Hanya Merchandiser yang dapat mengambil Sourcing Job.") @@ -145,17 +257,52 @@ class SourcingJobOrder(models.Model): rec.message_post(body=("Job <b>%s</b> diambil oleh %s") % (rec.name, self.env.user.name)) def action_multi_take(self): + context_action = self.env.context.get('from_multi_action_take', True) untaken = self.filtered(lambda r: r.state == 'draft') if not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'): raise UserError("❌ Hanya Merchandiser yang bisa mengambil Sourcing Job.") if not untaken: - raise UserError("Tidak ada record draft untuk diambil.") + raise UserError("Tidak ada record Untaken untuk diambil.") untaken.write({ 'state': 'taken', 'user_id': self.env.user.id, }) + def action_confirm_by_md(self): + for rec in self: + if rec.user_id and rec.user_id != self.env.user: + raise UserError("❌ Hanya MD Person yang memiliki SJO ini yang boleh melakukan Confirm.") + + invalid_lines = rec.line_md_edit_ids.filtered(lambda l: l.state not in ('done', 'cancel')) + if invalid_lines: + line_names = ', '.join(invalid_lines.mapped('product_name')) + raise UserError( + f"⚠️ Tidak dapat melakukan Confirm SJO.\n" + f"Masih ada line yang belum selesai disourcing: {line_names}" + ) + if rec.line_md_edit_ids and all(line.state == 'cancel' for line in rec.line_md_edit_ids): + raise UserError("⚠️ Tidak dapat melakukan Confirm SJO. Semua line pada SJO ini Unavailable.") + + rec.approval_sales = 'approve' + rec.state = 'done' + + rec.message_post( + body=f"Sourcing Job <b>{rec.name}</b> otomatis disetujui karena pembuat dan MD adalah orang yang sama (<b>{self.env.user.name}</b>).", + subtype_xmlid="mail.mt_comment" + ) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'titl': 'Auto Approved', + 'message': f"Sourcing Job '{rec.name}' otomatis disetujui dan diselesaikan.", + 'type': 'success', + 'sticky': False, + } + } + def action_cancel(self): for rec in self: if not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'): @@ -172,6 +319,7 @@ class SourcingJobOrder(models.Model): (rec.name, self.env.user.name, rec.cancel_reason)) def action_request_takeover(self): + context_action = self.env.context.get('from_action_takeover', True) for rec in self: if not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'): raise UserError("❌ Hanya Merchandiser yang dapat Request Takeover Sourcing Job.") @@ -182,47 +330,14 @@ class SourcingJobOrder(models.Model): rec.takeover_request = self.env.user activity_type = self.env.ref('mail.mail_activity_data_todo') - self.env['mail.activity'].create({ - 'activity_type_id': activity_type.id, - 'note': f"{self.env.user.name} meminta approval untuk mengambil alih SJO '{rec.name}'.", - 'res_id': rec.id, - 'res_model_id': self.env['ir.model']._get_id('sourcing.job.order'), - 'user_id': rec.user_id.id, - }) - - rec.message_post( - body=f"<b>{self.env.user.name}</b> mengirimkan request takeover kepada <b>{rec.user_id.name}</b>.", - subtype_xmlid="mail.mt_comment" + rec.activity_schedule( + activity_type_id=activity_type.id, + user_id=rec.user_id.id, + note=f"{self.env.user.name} meminta approval untuk mengambil alih SJO '{rec.name}'.", ) - return { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'title': 'Request Sent', - 'message': f"Request takeover telah dikirim ke {rec.user_id.name}.", - 'type': 'success', - 'sticky': False, - } - } - def action_ask_approval(self): - for rec in self: - if rec.user_id != self.env.user: - raise UserError("❌ Hanya MD Person Sourcing Job ini yang dapat Request Approval.") - - rec.approval_sales = 'requested' - - activity_type = self.env.ref('mail.mail_activity_data_todo') - self.env['mail.activity'].create({ - 'activity_type_id': activity_type.id, - 'note': f"{self.env.user.name} meminta approval untuk mengambil alih SJO '{rec.name}'.", - 'res_id': rec.id, - 'res_model_id': self.env['ir.model']._get_id('sourcing.job.order'), - 'user_id': rec.user_id.id, - }) - rec.message_post( - body=f"<b>{self.env.user.name}</b> mengirimkan request approval kepada <b>{rec.user_id.name}</b>.", + body=f"<b>{self.env.user.name}</b> mengirimkan request takeover kepada <b>{rec.user_id.name}</b>.", subtype_xmlid="mail.mt_comment" ) @@ -236,9 +351,7 @@ class SourcingJobOrder(models.Model): 'sticky': False, } } - - - + def action_approve_takeover(self): for rec in self: if self.env.user != rec.user_id: @@ -262,6 +375,7 @@ class SourcingJobOrder(models.Model): body=f"Takeover disetujui oleh <b>{self.env.user.name}</b>. Sourcing Job berpindah ke <b>{new_user.name}</b>.", subtype_xmlid="mail.mt_comment" ) + def action_reject_takeover(self): for rec in self: if self.env.user != rec.user_id: @@ -284,7 +398,98 @@ class SourcingJobOrder(models.Model): subtype_xmlid="mail.mt_comment" ) + def action_ask_approval(self): + for rec in self: + if rec.user_id != self.env.user: + raise UserError("❌ Hanya MD Person Sourcing Job ini yang dapat Request Approval.") + invalid_lines = rec.line_md_edit_ids.filtered(lambda l: l.state not in ('done', 'cancel')) + if invalid_lines: + line_names = ', '.join(invalid_lines.mapped('product_name')) + raise UserError( + f"⚠️ Tidak dapat melakukan Request Approval.\n" + f"Masih ada line yang belum selesai disourcing: {line_names}" + ) + if rec.line_md_edit_ids and all(line.state == 'cancel' for line in rec.line_md_edit_ids): + raise UserError("⚠️ Tidak dapat melakukan Request Approval. Semua line pada SJO ini Unavailable.") + + if rec.create_uid == rec.user_id: + rec.approval_sales = 'approve' + rec.state = 'done' + + rec.message_post( + body=f"Sourcing Job <b>{rec.name}</b> otomatis disetujui karena pembuat dan MD adalah orang yang sama (<b>{self.env.user.name}</b>).", + subtype_xmlid="mail.mt_comment" + ) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Auto Approved', + 'message': f"Sourcing Job '{rec.name}' otomatis disetujui dan diselesaikan.", + 'type': 'success', + 'sticky': False, + } + } + + rec.approval_sales = 'draft' + + activity_type = self.env.ref('mail.mail_activity_data_todo') + rec.activity_schedule( + activity_type_id=activity_type.id, + user_id=rec.create_uid.id, + note=f"{self.env.user.name} meminta approval untuk SJO '{rec.name}'.", + ) + + rec.message_post( + body=f"<b>{self.env.user.name}</b> mengirimkan request approval kepada <b>{rec.create_uid.name}</b>.", + subtype_xmlid="mail.mt_comment" + ) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Request Sent', + 'message': f"Request Approval telah dikirim ke {rec.create_uid.name}.", + 'type': 'success', + 'sticky': False, + } + } + + def action_confirm_approval(self): + for rec in self: + if rec.create_uid != self.env.user: + raise UserError("❌ Hanya Pembuat Sourcing Job ini yang dapat Confirm Approval.") + rec.approval_sales = 'approve' + rec.state = 'done' + + rec.activity_feedback(['mail.mail_activity_data_todo']) + + rec.message_post( + body=f"Sourcing Job disetujui oleh <b>{self.env.user.name}</b>.", + subtype_xmlid="mail.mt_comment" + ) + + rec.activity_schedule( + 'mail.mail_activity_data_todo', + user_id=rec.user_id.id, + note=f"✅ Sourcing Job <b>{rec.name}</b> telah disetujui oleh {self.env.user.name}.", + ) + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Approval Confirmed', + 'message': f"Sourcing Job '{rec.name}' telah disetujui dan dikirim ke {rec.user_id.name}.", + 'type': 'success', + 'sticky': False, + } + } + + class SourcingJobOrderLine(models.Model): _name = 'sourcing.job.order.line' _description = 'Sourcing Job Order Line' @@ -293,15 +498,19 @@ class SourcingJobOrderLine(models.Model): product_name = fields.Char(string='Nama Barang', required=True) code = fields.Char(string='SKU') descriptions = fields.Text(string='Deskripsi / Spesifikasi') + reason = fields.Text(string='Reason Unavailable') + sla = fields.Char(string='SLA Product') quantity = fields.Float(string='Quantity Product', required=True) price = fields.Float(string='Purchase Price') tax_id = fields.Many2one('account.tax', string='Tax', domain=[('active', '=', True)]) vendor_id = fields.Many2one('res.partner', string="Vendor") product_category = fields.Many2one('product.category', string="Product Category") + product_class = fields.Many2one('product.public.category', string="Categories") state = fields.Selection([ ('draft', 'Unsource'), ('sourcing', 'On Sourcing'), ('done', 'Done Sourcing'), + ('convert', 'Converted'), ('cancel', 'Unavailable') ], default='draft', tracking=True) product_type = fields.Selection([ @@ -324,12 +533,18 @@ class SourcingJobOrderLine(models.Model): subtotal += subtotal * (line.tax_id.amount / 100) line.subtotal = subtotal - @api.constrains('product_type', 'product_category') + @api.constrains('product_type', 'product_category', 'product_class', 'code') def _check_required_fields_for_md(self): for rec in self: is_md = self.env.user.has_group('indoteknik_custom.group_role_merchandiser') - if is_md and (not rec.product_type or not rec.product_category): - raise ValidationError("MD wajib mengisi Product Type dan Product Category!") + if is_md and (not rec.product_type or not rec.product_category or not rec.product_class or not rec.code): + raise UserError("MD wajib mengisi SKU, Product Type, Product Category, dan Categories!") + + @api.depends('state') + def _check_unavailable_line(self): + for rec in self: + if rec.state == 'cancel' and not rec.reason: + raise UserError("Isi Reason Unavailable") @api.depends('price', 'vendor_id', 'order_id') def _compute_show_for_sales(self): @@ -337,3 +552,47 @@ class SourcingJobOrderLine(models.Model): rec.show_for_sales = bool( rec.order_id and rec.price not in (None, 0) and rec.vendor_id ) + + def action_convert_to_product(self): + type_map = { + 'servis': 'service', + 'product': 'product', + 'consu': 'consu', + } + + for rec in self: + if rec.order_id.user_id != self.env.user: + raise UserError("❌ Hanya MD Person SJO ini yang dapat convert Line ini ke product.") + exsisting = self.env['product.product'].search([('default_code', '=', rec.code)], limit=1) + + if exsisting: + raise UserError(f"⚠️ Produk dengan Internal Reference '{rec.code}' sudah ada di sistem.") + + product = self.env['product.product'].create({ + 'name': rec.product_name, + 'default_code': rec.code or False, + 'description': rec.descriptions or '', + 'categ_id': rec.product_category.id, + 'type': type_map.get(rec.product_type, 'product'), + }) + + rec.state = 'convert' + return True + + @api.onchange('code') + def _oncange_code(self): + for rec in self: + if not rec.code: + continue + + product = self.env['product.product'].search([('default_code', '=', rec.code)], limit=1) + if not product: + return + + rec.product_name = product.name or rec.product_name + + pricelist = self.env['purchase.pricelist'].search([('product_id', '=', product.id), ('is_winner', '=', True)], limit=1) + if pricelist or tax or vendor: + rec.vendor_id = pricelist.vendor_id.id or False + rec.price = pricelist.include_price or 0.0 + rec.tax_id = pricelist.taxes_product_id.id or pricelist.taxes_system_id.id or False
\ No newline at end of file diff --git a/indoteknik_custom/models/stock_move.py b/indoteknik_custom/models/stock_move.py index 1da2befe..8f8ba66f 100644 --- a/indoteknik_custom/models/stock_move.py +++ b/indoteknik_custom/models/stock_move.py @@ -186,6 +186,56 @@ class StockMoveLine(models.Model): line_no = fields.Integer('No', default=0) note = fields.Char('Note') manufacture = fields.Many2one('x_manufactures', string="Brands", related="product_id.x_manufacture", store=True) + outstanding_qty = fields.Float( + string='Outstanding Qty', + compute='_compute_delivery_line_status', + store=False + ) + delivery_status = fields.Selection([ + ('none', 'No Movement'), + ('partial', 'Partial'), + ('partial_final', 'Partial Final'), + ('full', 'Full'), + ], string='Delivery Status', compute='_compute_delivery_line_status', store=False) + + @api.depends('qty_done', 'product_uom_qty', 'picking_id.state') + def _compute_delivery_line_status(self): + for line in self: + line.outstanding_qty = 0.0 + line.delivery_status = 'none' + + picking = line.picking_id + if not picking or picking.picking_type_id.code != 'outgoing': + continue + + total_qty = line.move_id.product_uom_qty or 0 + done_qty = line.qty_done or 0 + + line.outstanding_qty = max(total_qty - done_qty, 0) + + if total_qty == 0: + continue + + if done_qty == 0: + line.delivery_status = 'none' + elif done_qty > 0: + has_other_out = self.env['stock.picking'].search_count([ + ('group_id', '=', picking.group_id.id), + ('name', 'ilike', 'BU/OUT'), + ('id', '!=', picking.id), + ('state', '=', 'done'), + ]) + if has_other_out and done_qty == total_qty: + line.delivery_status = 'partial_final' + elif not has_other_out and done_qty >= total_qty: + line.delivery_status = 'full' + elif has_other_out and done_qty < total_qty: + line.delivery_status = 'partial' + elif done_qty < total_qty: + line.delivery_status = 'partial' + else: + line.delivery_status = 'none' + # Ambil uom dari stock move @api.model diff --git a/indoteknik_custom/models/stock_picking.py b/indoteknik_custom/models/stock_picking.py index d6096cc0..7f8523a3 100644 --- a/indoteknik_custom/models/stock_picking.py +++ b/indoteknik_custom/models/stock_picking.py @@ -177,6 +177,97 @@ class StockPicking(models.Model): area_name = fields.Char(string="Area", compute="_compute_area_name") is_bu_iu = fields.Boolean('Is BU/IU', compute='_compute_is_bu_iu', default=False, copy=False, readonl=True) + qty_yang_mau_dikirim = fields.Float( + string='Qty yang Mau Dikirim', + compute='_compute_delivery_status_detail', + store=False + ) + qty_terkirim = fields.Float( + string='Qty Terkirim', + compute='_compute_delivery_status_detail', + store=False + ) + qty_gantung = fields.Float( + string='Qty Gantung', + compute='_compute_delivery_status_detail', + store=False + ) + delivery_status = fields.Selection([ + ('none', 'No Movement'), + ('partial', 'Partial'), + ('partial_final', 'Partial Final'), + ('full', 'Full'), + ], string='Delivery Status', compute='_compute_delivery_status_detail', store=False) + + @api.depends('move_line_ids_without_package.qty_done', 'move_line_ids_without_package.product_uom_qty', 'state') + def _compute_delivery_status_detail(self): + for picking in self: + # Default values + picking.qty_yang_mau_dikirim = 0.0 + picking.qty_terkirim = 0.0 + picking.qty_gantung = 0.0 + picking.delivery_status = 'none' + + # Hanya berlaku untuk pengiriman (BU/OUT) + if picking.picking_type_id.code != 'outgoing': + continue + + move_lines = picking.move_line_ids_without_package + if not move_lines: + continue + + # ====================== + # HITUNG QTY + # ====================== + total_qty = sum(line.product_uom_qty for line in move_lines) + + done_qty_total = sum(line.sale_line_id.qty_delivered for line in picking.move_ids_without_package) + order_qty_total = sum(line.sale_line_id.product_uom_qty for line in picking.move_ids_without_package) + gantung_qty_total = order_qty_total - done_qty_total - total_qty + + picking.qty_yang_mau_dikirim = total_qty + picking.qty_terkirim = done_qty_total + picking.qty_gantung = gantung_qty_total + + # if total_qty == 0: + # picking.delivery_status = 'none' + # continue + + # if done_qty_total == 0: + # picking.delivery_status = 'none' + # continue + + # ====================== + # CEK BU/OUT LAIN (BACKORDER) + # ====================== + has_other_out = self.env['stock.picking'].search_count([ + ('group_id', '=', picking.group_id.id), + ('name', 'ilike', 'BU/OUT'), + ('id', '!=', picking.id), + ('state', 'in', ['assigned', 'waiting', 'confirmed', 'done']), + ]) + + # ====================== + # LOGIKA STATUS + # ====================== + if gantung_qty_total == 0 and done_qty_total == 0: + # Semua barang udah terkirim, ga ada picking lain + picking.delivery_status = 'full' + + elif gantung_qty_total > 0 and total_qty > 0 and done_qty_total == 0: + # Masih ada picking lain dan sisa gantung → proses masih jalan + picking.delivery_status = 'partial' + + # elif gantung_qty_total > 0: + # # Ini picking terakhir, tapi qty belum full + # picking.delivery_status = 'partial_final' + + elif gantung_qty_total == 0 and done_qty_total > 0 and total_qty > 0: + # Udah kirim semua tapi masih ada picking lain (rare case) + picking.delivery_status = 'partial_final' + + else: + picking.delivery_status = 'none' @api.depends('name') def _compute_is_bu_iu(self): diff --git a/indoteknik_custom/models/tukar_guling.py b/indoteknik_custom/models/tukar_guling.py index cb630a04..99a74505 100644 --- a/indoteknik_custom/models/tukar_guling.py +++ b/indoteknik_custom/models/tukar_guling.py @@ -165,7 +165,7 @@ class TukarGuling(models.Model): @api.onchange('operations') def _onchange_operations(self): """Auto-populate lines ketika operations dipilih""" - if self.operations.picking_type_id.id not in [29, 30]: + if self.operations.picking_type_id.id not in [29, 30] and self.env.user.id != 1102: raise UserError("❌ Picking type harus BU/OUT atau BU/PICK") for rec in self: if rec.operations and rec.operations.picking_type_id.id == 30: @@ -412,7 +412,7 @@ class TukarGuling(models.Model): def write(self, vals): self.ensure_one() - if self.operations.picking_type_id.id not in [29, 30]: + if self.operations.picking_type_id.id not in [29, 30] and self.env.user.id != 1102: raise UserError("❌ Picking type harus BU/OUT atau BU/PICK") # self._check_invoice_on_retur_so() operasi = self.operations.picking_type_id.id @@ -498,8 +498,8 @@ class TukarGuling(models.Model): ('state', '!=', 'cancel'), ], limit=1) - if existing_tukar_guling: - raise UserError("BU ini sudah pernah diretur oleh dokumen %s." % existing_tukar_guling.name) + # if existing_tukar_guling: + # raise UserError("BU ini sudah pernah diretur oleh dokumen %s." % existing_tukar_guling.name) picking = self.operations if picking.picking_type_id.id == 30 and self.return_type == 'tukar_guling': raise UserError("❌ BU/PICK tidak boleh di retur tukar guling") |
