summaryrefslogtreecommitdiff
path: root/indoteknik_custom/models
diff options
context:
space:
mode:
Diffstat (limited to 'indoteknik_custom/models')
-rwxr-xr-xindoteknik_custom/models/__init__.py2
-rw-r--r--indoteknik_custom/models/account_move.py132
-rw-r--r--indoteknik_custom/models/approval_payment_term.py187
-rw-r--r--indoteknik_custom/models/commision.py5
-rw-r--r--indoteknik_custom/models/dunning_run.py20
-rw-r--r--indoteknik_custom/models/patch/__init__.py1
-rw-r--r--indoteknik_custom/models/patch/http_override.py45
-rw-r--r--indoteknik_custom/models/res_partner.py5
-rwxr-xr-xindoteknik_custom/models/sale_order.py4
-rw-r--r--indoteknik_custom/models/sale_order_line.py1
-rw-r--r--indoteknik_custom/models/stock_picking.py328
11 files changed, 610 insertions, 120 deletions
diff --git a/indoteknik_custom/models/__init__.py b/indoteknik_custom/models/__init__.py
index 903c0745..84f2e15a 100755
--- a/indoteknik_custom/models/__init__.py
+++ b/indoteknik_custom/models/__init__.py
@@ -151,5 +151,7 @@ from . import account_payment_register
from . import stock_inventory
from . import sale_order_delay
from . import approval_invoice_date
+from . import approval_payment_term
+# from . import patch
from . import tukar_guling
from . import tukar_guling_po
diff --git a/indoteknik_custom/models/account_move.py b/indoteknik_custom/models/account_move.py
index af24f93e..72ac5452 100644
--- a/indoteknik_custom/models/account_move.py
+++ b/indoteknik_custom/models/account_move.py
@@ -8,12 +8,15 @@ import PyPDF2
import os
import re
from terbilang import Terbilang
+from collections import defaultdict
+from odoo.tools.misc import formatLang
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = 'account.move'
+ _description = 'Account Move'
invoice_day_to_due = fields.Integer(string="Day to Due", compute="_compute_invoice_day_to_due")
bill_day_to_due = fields.Date(string="Day to Due", compute="_compute_bill_day_to_due")
date_send_fp = fields.Datetime(string="Tanggal Kirim Faktur Pajak")
@@ -72,21 +75,103 @@ class AccountMove(models.Model):
bill_id = fields.Many2one('account.move', string='Vendor Bill', domain=[('move_type', '=', 'in_invoice')], help='Bill asal dari proses reklas ini')
down_payment = fields.Boolean('Down Payments?')
+ def send_due_invoice_reminder(self):
+ today = fields.Date.today()
+ target_dates = [
+ today - timedelta(days=7),
+ today - timedelta(days=3),
+ today,
+ today + timedelta(days=3),
+ today + timedelta(days=7),
+ ]
+
+ partner = self.env['res.partner'].search([('name', 'ilike', 'BANGUNAN TEKNIK GRUP')], limit=1)
+ if not partner:
+ _logger.info("Partner tidak ditemukan.")
+ return
+
+ invoices = self.env['account.move'].search([
+ ('move_type', '=', 'out_invoice'),
+ ('state', '=', 'posted'),
+ ('payment_state', 'not in', ['paid','in_payment', 'reversed']),
+ ('invoice_date_due', 'in', target_dates),
+ ('partner_id', '=', partner.id),
+ ])
+
+ _logger.info(f"Invoices tahap 1: {invoices}")
+
+ invoices = invoices.filtered(
+ lambda inv: inv.invoice_payment_term_id and 'tempo' in (inv.invoice_payment_term_id.name or '').lower()
+ )
+ _logger.info(f"Invoices tahap 2: {invoices}")
+
+ if not invoices:
+ _logger.info(f"Tidak ada invoice yang due untuk partner: {partner.name}")
+ return
+
+ grouped = {}
+ for inv in invoices:
+ grouped.setdefault(inv.partner_id, []).append(inv)
+
+ template = self.env.ref('indoteknik_custom.mail_template_invoice_due_reminder')
+
+ for partner, invs in grouped.items():
+ if not partner.email:
+ _logger.info(f"Partner {partner.name} tidak memiliki email")
+ continue
+
+ invoice_table_rows = ""
+ for inv in invs:
+ days_to_due = (inv.invoice_date_due - today).days if inv.invoice_date_due else 0
+ invoice_table_rows += f"""
+ <tr>
+ <td>{inv.name}</td>
+ <td>{fields.Date.to_string(inv.invoice_date) or '-'}</td>
+ <td>{fields.Date.to_string(inv.invoice_date_due) or '-'}</td>
+ <td>{days_to_due}</td>
+ <td>{formatLang(self.env, inv.amount_total, currency_obj=inv.currency_id)}</td>
+ <td>{inv.ref or '-'}</td>
+ </tr>
+ """
+
+ subject = f"Reminder Invoice Due - {partner.name}"
+ body_html = re.sub(
+ r"<tbody[^>]*>.*?</tbody>",
+ f"<tbody>{invoice_table_rows}</tbody>",
+ template.body_html,
+ flags=re.DOTALL
+ ).replace('${object.name}', partner.name) \
+ .replace('${object.partner_id.name}', partner.name)
+ # .replace('${object.email}', partner.email or '')
+
+ values = {
+ 'subject': subject,
+ 'email_to': 'andrifebriyadiputra@gmail.com', # Ubah ke partner.email untuk produksi
+ 'email_from': 'finance@indoteknik.co.id',
+ 'body_html': body_html,
+ 'reply_to': f'invoice+account.move_{invs[0].id}@indoteknik.co.id',
+ }
+
+ _logger.info(f"VALUES: {values}")
+
+ template.send_mail(invs[0].id, force_send=True, email_values=values)
+
+ # Default System User
+ user_system = self.env['res.users'].browse(25)
+ system_id = user_system.partner_id.id if user_system else False
+ _logger.info(f"System User: {user_system.name} ({user_system.id})")
+ _logger.info(f"System User ID: {system_id}")
+
+ for inv in invs:
+ inv.message_post(
+ subject=subject,
+ body=body_html,
+ subtype_id=self.env.ref('mail.mt_note').id,
+ author_id=system_id,
+ )
+
+ _logger.info(f"Reminder terkirim ke {partner.name} ({values['email_to']}) → {len(invs)} invoice")
- # def name_get(self):
- # result = []
- # for move in self:
- # if move.move_type == 'entry':
- # # Jika masih draft, tampilkan 'Draft CAB'
- # if move.state == 'draft':
- # label = 'Draft CAB'
- # else:
- # label = move.name
- # result.append((move.id, label))
- # else:
- # # Untuk invoice dan lainnya, pakai default
- # result.append((move.id, move.display_name))
- # return result
@api.onchange('invoice_date')
def _onchange_invoice_date(self):
@@ -99,12 +184,27 @@ class AccountMove(models.Model):
self.invoice_date = self.date
+ # def compute_length_of_payment(self):
+ # for rec in self:
+ # payment_term = rec.invoice_payment_term_id.line_ids[0].days
+ # terima_faktur = rec.date_terima_tukar_faktur
+ # payment = self.search([('ref', '=', rec.name), ('move_type', '=', 'entry')], limit=1)
+
+ # if payment and terima_faktur:
+ # date_diff = terima_faktur - payment.date
+ # rec.length_of_payment = date_diff.days + payment_term
+ # else:
+ # rec.length_of_payment = 0
+
def compute_length_of_payment(self):
for rec in self:
- payment_term = rec.invoice_payment_term_id.line_ids[0].days
+ payment_term = 0
+ if rec.invoice_payment_term_id and rec.invoice_payment_term_id.line_ids:
+ payment_term = rec.invoice_payment_term_id.line_ids[0].days
+
terima_faktur = rec.date_terima_tukar_faktur
payment = self.search([('ref', '=', rec.name), ('move_type', '=', 'entry')], limit=1)
-
+
if payment and terima_faktur:
date_diff = terima_faktur - payment.date
rec.length_of_payment = date_diff.days + payment_term
diff --git a/indoteknik_custom/models/approval_payment_term.py b/indoteknik_custom/models/approval_payment_term.py
new file mode 100644
index 00000000..025f9ed4
--- /dev/null
+++ b/indoteknik_custom/models/approval_payment_term.py
@@ -0,0 +1,187 @@
+from odoo import models, api, fields
+from odoo.exceptions import AccessError, UserError, ValidationError
+from datetime import timedelta, date, datetime
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class ApprovalPaymentTerm(models.Model):
+ _name = "approval.payment.term"
+ _description = "Approval Payment Term"
+ _inherit = ['mail.thread']
+ _rec_name = 'number'
+
+ number = fields.Char(string='Document No', index=True, copy=False, readonly=True, tracking=True)
+ partner_id = fields.Many2one('res.partner', string='Partner', copy=False)
+ property_payment_term_id = fields.Many2one('account.payment.term', string='Payment Term', copy=False, tracking=True)
+ parent_id = fields.Many2one('res.partner', string='Related Company', copy=False)
+ blocking_stage = fields.Float(string='Blocking Amount',
+ help="Cannot make sales once the selected "
+ "customer is crossed blocking amount."
+ "Set its value to 0.00 to disable "
+ "this feature", tracking=True, copy=False)
+ warning_stage = fields.Float(string='Warning Amount',
+ help="A warning message will appear once the "
+ "selected customer is crossed warning "
+ "amount. Set its value to 0.00 to"
+ " disable this feature", tracking=True, copy=False)
+ active_limit = fields.Boolean('Active Credit Limit', copy=False, tracking=True)
+ approve_sales_manager = fields.Boolean('Approve Sales Manager', tracking=True, copy=False)
+ approve_finance = fields.Boolean('Approve Finance', tracking=True, copy=False)
+ approve_leader = fields.Boolean('Approve Pimpinan', tracking=True, copy=False)
+ reason = fields.Text('Reason', tracking=True)
+ approve_date = fields.Datetime('Approve Date')
+ state = fields.Selection([
+ ('waiting_approval_sales_manager', 'Waiting Approval Sales Manager'),
+ ('waiting_approval_finance', 'Waiting Approval Finance'),
+ ('waiting_approval_leader', 'Waiting Approval Leader'),
+ ('approved', 'Approved'),
+ ('rejected', 'Rejected')],
+ default='waiting_approval_sales_manager', tracking=True)
+ reason_reject = fields.Selection([('reason1', 'Reason 1'), ('reason2', 'Reason 2'), ('reason3', 'Reason 3')], string='Reason Reject', tracking=True)
+ sale_order_ids = fields.Many2many(
+ 'sale.order',
+ string='Sale Orders',
+ copy=False,
+ tracking=True
+ )
+
+ total = fields.Char(
+ string='Sale Order Totals',
+ compute='_compute_total'
+ )
+
+ grand_total = fields.Float(string='Grand Total', compute="_compute_grand_total")
+
+ change_log_688 = fields.Text(string="Change Log", readonly=True, copy=False)
+
+ def write(self, vals):
+ # Ambil nilai lama sebelum perubahan
+ old_values_dict = {
+ rec.id: rec.read(vals.keys())[0]
+ for rec in self
+ }
+
+ res = super().write(vals)
+
+ self._track_changes_for_user_688(vals, old_values_dict)
+ return res
+
+ def _track_changes_for_user_688(self, vals, old_values_dict):
+ if self.env.user.id != 688:
+ return
+
+ for rec in self:
+ changes = []
+ old_values = old_values_dict.get(rec.id, {})
+
+ for field_name, new_value in vals.items():
+ if field_name not in rec._fields or field_name == 'change_log_688':
+ continue
+
+ field = rec._fields[field_name]
+ old_value = old_values.get(field_name)
+
+ field_label = field.string # Ambil label user-friendly
+
+ # Relational field
+ if field.type == 'many2one':
+ old_id = old_value[0] if old_value else False
+ is_different = old_id != new_value
+ if is_different:
+ old_display = old_value[1] if old_value else 'False'
+ new_display = rec.env[field.comodel_name].browse(new_value).display_name if new_value else 'False'
+ changes.append(f"[{field_label}] dari '{old_display}' ke '{new_display}'")
+
+ else:
+ # Float khusus
+ if field.type == 'float':
+ is_different = not self._float_equal(old_value, new_value)
+ else:
+ is_different = old_value != new_value
+
+ if is_different:
+ changes.append(f"[{field_label}] dari '{old_value}' ke '{new_value}'")
+
+ if changes:
+ timestamp = fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ rec.change_log_688 = f"{timestamp} - Perubahan oleh Widya:\n" + "\n".join(changes)
+
+
+ @staticmethod
+ def _float_equal(val1, val2, eps=1e-6):
+ try:
+ return abs(float(val1 or 0.0) - float(val2 or 0.0)) < eps
+ except Exception:
+ return False
+
+ def _compute_grand_total(self):
+ for rec in self:
+ grand_total = sum(order.amount_total for order in rec.sale_order_ids)
+ rec.grand_total = grand_total
+
+ def _compute_total(self):
+ for rec in self:
+ totals_list = []
+ for order in rec.sale_order_ids:
+ formatted_total = "{:,.2f}".format(order.amount_total)
+ totals_list.append(f"{order.name}: {formatted_total}")
+
+ rec.total = "\n".join(totals_list) if totals_list else "No Sale Orders"
+
+
+ @api.constrains('partner_id')
+ def constrains_partner_id(self):
+ if self.partner_id:
+ self.parent_id = self.partner_id.parent_id.id if self.partner_id.parent_id else None
+ self.blocking_stage = self.partner_id.blocking_stage
+ self.warning_stage = self.partner_id.warning_stage
+ self.active_limit = self.partner_id.active_limit
+ self.property_payment_term_id = self.partner_id.property_payment_term_id.id
+
+ def button_approve(self):
+ user = self.env.user
+ is_it = user.has_group('indoteknik_custom.group_role_it')
+
+ if (not user.id ==7 and user.id == 19 and not self.approve_sales_manager) or is_it:
+ self.approve_sales_manager = True
+ self.state = 'waiting_approval_finance'
+ return
+
+ if (not user.id ==7 and user.id == 688 and not self.approve_finance) or is_it:
+ self.approve_finance = True
+ self.state = 'waiting_approval_leader'
+ return
+
+ if (user.id == 7 and self.approve_finance) or is_it:
+ self.approve_leader = True
+
+ if not self.approve_finance and not is_it:
+ raise UserError('Harus Approval Finance!!')
+ if not self.approve_leader and not is_it:
+ raise UserError('Harus Approval Pimpinan!!')
+
+ if user.id == 7:
+ if not self.approve_finance:
+ raise UserError('Belum Di Approve Oleh Finance')
+
+ if self.approve_leader == True:
+ self.partner_id.write({
+ 'blocking_stage': self.blocking_stage,
+ 'warning_stage': self.warning_stage,
+ 'active_limit': self.active_limit,
+ 'property_payment_term_id': self.property_payment_term_id.id
+ })
+ self.approve_date = datetime.utcnow()
+ self.state = 'approved'
+
+ def button_reject(self):
+ if self.env.user.id not in [688, 7]:
+ raise UserError("Hanya Finance atau Pimpinan Yang Bisa Reject")
+ self.state = 'rejected'
+
+ @api.model
+ def create(self, vals):
+ vals['number'] = self.env['ir.sequence'].next_by_code('approval.payment.term') or '0'
+ result = super(ApprovalPaymentTerm, self).create(vals)
+ return result
diff --git a/indoteknik_custom/models/commision.py b/indoteknik_custom/models/commision.py
index 97184cdb..26b5df37 100644
--- a/indoteknik_custom/models/commision.py
+++ b/indoteknik_custom/models/commision.py
@@ -193,6 +193,7 @@ class CustomerCommision(models.Model):
commision_amt_text = fields.Char(string='Amount Text', compute='compute_delivery_amt_text')
total_cashback_text = fields.Char(string='Cashback Text', compute='compute_total_cashback_text')
total_dpp = fields.Float(string='Total DPP', compute='_compute_total_dpp')
+ biaya_lain_lain = fields.Float(string='Biaya Lain-lain')
commision_type = fields.Selection([
('fee', 'Fee'),
('cashback', 'Cashback'),
@@ -363,13 +364,13 @@ class CustomerCommision(models.Model):
else:
self.cashback = 0
self.total_commision = 0
-
+
def _compute_total_dpp(self):
for data in self:
total_dpp = 0
for line in data.commision_lines:
total_dpp = total_dpp + line.dpp
- data.total_dpp = total_dpp
+ data.total_dpp = total_dpp - data.biaya_lain_lain
@api.model
def create(self, vals):
diff --git a/indoteknik_custom/models/dunning_run.py b/indoteknik_custom/models/dunning_run.py
index bb53fc0c..5a6aebac 100644
--- a/indoteknik_custom/models/dunning_run.py
+++ b/indoteknik_custom/models/dunning_run.py
@@ -92,10 +92,23 @@ class DunningRun(models.Model):
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('partner_id', '=', partner.id),
- # ('amount_residual_signed', '>', 0),
('date_kirim_tukar_faktur', '=', False),
]
- invoices = self.env['account.move'].search(query, order='invoice_date')
+ invoices = self.env['account.move'].search(query)
+
+ # sort full berdasarkan tahun, bulan, nomor
+ def invoice_key(x):
+ try:
+ parts = x.name.split('/')
+ tahun = int(parts[1])
+ bulan = int(parts[2])
+ nomor = int(parts[3])
+ return (tahun, bulan, nomor)
+ except Exception:
+ return (0, 0, 0)
+
+ invoices = sorted(invoices, key=invoice_key)
+
count = 0
for invoice in invoices:
self.env['dunning.run.line'].create([{
@@ -123,8 +136,9 @@ class DunningRunLine(models.Model):
_name = 'dunning.run.line'
_description = 'Dunning Run Line'
# _order = 'dunning_id, id'
- _order = 'invoice_id desc, id'
+ _order = 'invoice_number asc, id'
+ invoice_number = fields.Char('Invoice Number', related='invoice_id.name')
dunning_id = fields.Many2one('dunning.run', string='Dunning Ref', required=True, ondelete='cascade', index=True, copy=False)
partner_id = fields.Many2one('res.partner', string='Customer')
invoice_id = fields.Many2one('account.move', string='Invoice')
diff --git a/indoteknik_custom/models/patch/__init__.py b/indoteknik_custom/models/patch/__init__.py
new file mode 100644
index 00000000..051b6537
--- /dev/null
+++ b/indoteknik_custom/models/patch/__init__.py
@@ -0,0 +1 @@
+from . import http_override \ No newline at end of file
diff --git a/indoteknik_custom/models/patch/http_override.py b/indoteknik_custom/models/patch/http_override.py
new file mode 100644
index 00000000..6bec1343
--- /dev/null
+++ b/indoteknik_custom/models/patch/http_override.py
@@ -0,0 +1,45 @@
+import odoo.http
+import json
+import logging
+from werkzeug.exceptions import BadRequest
+import functools
+
+_logger = logging.getLogger(__name__)
+
+class CustomJsonRequest(odoo.http.JsonRequest):
+ def __init__(self, httprequest):
+ super(odoo.http.JsonRequest, self).__init__(httprequest)
+
+ self.params = {}
+ request_data_raw = self.httprequest.get_data().decode(self.httprequest.charset)
+
+ self.jsonrequest = {}
+ if request_data_raw.strip():
+ try:
+ self.jsonrequest = json.loads(request_data_raw)
+ except ValueError:
+ msg = 'Invalid JSON data: %r' % (request_data_raw,)
+ _logger.info('%s: %s (Handled by CustomJsonRequest)', self.httprequest.path, msg)
+ raise BadRequest(msg)
+ else:
+ _logger.info("CustomJsonRequest: Received empty or whitespace-only JSON body. Treating as empty JSON for webhook.")
+
+ self.params = dict(self.jsonrequest.get("params", {}))
+ self.context = self.params.pop('context', dict(self.session.context))
+
+
+_original_get_request = odoo.http.Root.get_request
+
+@functools.wraps(_original_get_request)
+def _get_request_override(self, httprequest):
+ _logger.info("--- DEBUG: !!! _get_request_override IS CALLED !!! ---")
+ _logger.info(f"--- DEBUG: Request Mimetype: {httprequest.mimetype}, Path: {httprequest.path} ---")
+
+ if httprequest.mimetype in ("application/json", "application/json-rpc"):
+ _logger.debug("Odoo HTTP: Using CustomJsonRequest for mimetype: %s", httprequest.mimetype)
+ return CustomJsonRequest(httprequest)
+ else:
+ _logger.debug("Odoo HTTP: Using original get_request for mimetype: %s", httprequest.mimetype)
+ return _original_get_request(self, httprequest)
+
+odoo.http.Root.get_request = _get_request_override \ No newline at end of file
diff --git a/indoteknik_custom/models/res_partner.py b/indoteknik_custom/models/res_partner.py
index f5347bea..52947128 100644
--- a/indoteknik_custom/models/res_partner.py
+++ b/indoteknik_custom/models/res_partner.py
@@ -168,12 +168,17 @@ class ResPartner(models.Model):
payment_difficulty = fields.Selection([('bermasalah', 'Bermasalah'),('sulit', 'Sulit'),('agak_sulit', 'Agak Sulit'),('normal', 'Normal')], string='Payment Difficulty', compute="_compute_payment_difficulty", inverse = "_inverse_payment_difficulty", tracking=3)
payment_history_url = fields.Text(string='Payment History URL')
+ # no compute
+ # payment_diff = fields.Selection([('bermasalah', 'Bermasalah'),('sulit', 'Sulit'),('agak_sulit', 'Agak Sulit'),('normal', 'Normal')], string='Payment Difficulty', tracking=3)
+
+ # tidak terpakai
@api.depends('parent_id.payment_difficulty')
def _compute_payment_difficulty(self):
for partner in self:
if partner.parent_id:
partner.payment_difficulty = partner.parent_id.payment_difficulty
+ # tidak terpakai
def _inverse_payment_difficulty(self):
for partner in self:
if not partner.parent_id:
diff --git a/indoteknik_custom/models/sale_order.py b/indoteknik_custom/models/sale_order.py
index 591951ca..e197a6af 100755
--- a/indoteknik_custom/models/sale_order.py
+++ b/indoteknik_custom/models/sale_order.py
@@ -3048,6 +3048,10 @@ class SaleOrder(models.Model):
if picking.state == 'assigned':
picking.carrier_id = vals['carrier_id']
+ for picking in order.picking_ids:
+ if picking.state not in ['done', 'cancel', 'assigned']:
+ picking.write({'carrier_id': vals['carrier_id']})
+
try:
helper_ids = self._get_helper_ids()
if str(self.env.user.id) in helper_ids:
diff --git a/indoteknik_custom/models/sale_order_line.py b/indoteknik_custom/models/sale_order_line.py
index 291940ed..2a0160e8 100644
--- a/indoteknik_custom/models/sale_order_line.py
+++ b/indoteknik_custom/models/sale_order_line.py
@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
+
item_margin = fields.Float('Margin', compute='compute_item_margin', help="Total Margin in Sales Order Header")
item_before_margin = fields.Float('Before Margin', compute='compute_item_before_margin',
help="Total Margin in Sales Order Header")
diff --git a/indoteknik_custom/models/stock_picking.py b/indoteknik_custom/models/stock_picking.py
index 1827c489..f3d7c2b9 100644
--- a/indoteknik_custom/models/stock_picking.py
+++ b/indoteknik_custom/models/stock_picking.py
@@ -20,8 +20,10 @@ _logger = logging.getLogger(__name__)
_biteship_url = "https://api.biteship.com/v1"
biteship_api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo"
+
+
# biteship_api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA"
-
+
class StockPicking(models.Model):
_inherit = 'stock.picking'
@@ -123,7 +125,7 @@ class StockPicking(models.Model):
waybill_id = fields.One2many(comodel_name='airway.bill', inverse_name='do_id', string='Airway Bill')
purchase_representative_id = fields.Many2one('res.users', related='move_lines.purchase_line_id.order_id.user_id',
string="Purchase Representative")
- carrier_id = fields.Many2one('delivery.carrier', string='Shipping Method')
+ carrier_id = fields.Many2one('delivery.carrier', string='Shipping Method', tracking=3)
shipping_status = fields.Char(string='Shipping Status', compute="_compute_shipping_status")
date_reserved = fields.Datetime(string="Date Reserved", help='Tanggal ter-reserved semua barang nya', copy=False)
status_printed = fields.Selection([
@@ -275,16 +277,29 @@ class StockPicking(models.Model):
# Biteship Section
biteship_id = fields.Char(string="Biteship Respon ID")
- biteship_tracking_id = fields.Char(string="Biteship Trackcking ID")
+ biteship_tracking_id = fields.Char(string="Biteship Tracking ID")
biteship_waybill_id = fields.Char(string="Biteship Waybill ID")
+ biteship_driver_name = fields.Char('Biteship Driver Name')
+ biteship_driver_phone = fields.Char('Biteship Driver Phone')
+ biteship_driver_plate_number = fields.Char('Biteship Driver Plate Number')
+ biteship_courier_link = fields.Char('Biteship Courier Link')
+ biteship_shipping_status = fields.Char('Biteship Shipping Status', help="Status pengiriman dari Biteship")
+ biteship_shipping_price = fields.Monetary('Biteship Shipping Price', currency_field='currency_id',
+ help="Harga pengiriman dari Biteship")
+ currency_id = fields.Many2one('res.currency', related='sale_id.currency_id', string='Currency', readonly=True)
final_seq = fields.Float(string='Remaining Time')
- shipping_method_so_id = fields.Many2one('delivery.carrier', string='Shipping Method SO', related='sale_id.carrier_id')
- shipping_option_so_id = fields.Many2one('shipping.option', string='Shipping Option SO', related='sale_id.shipping_option_id')
+ shipping_method_so_id = fields.Many2one('delivery.carrier', string='Shipping Method', related='sale_id.carrier_id',
+ help="Shipping Method yang digunakan di SO", tracking=3)
+ shipping_option_so_id = fields.Many2one('shipping.option', string='Shipping Option',
+ related='sale_id.shipping_option_id',
+ help="Shipping Option yang digunakan di SO", tracking=3)
select_shipping_option_so = fields.Selection([
('biteship', 'Biteship'),
('custom', 'Custom'),
- ], string='Shipping Type SO', related='sale_id.select_shipping_option')
- state_packing = fields.Selection([('not_packing', 'Belum Packing'), ('packing_done', 'Sudah Packing')], string='Packing Status')
+ ], string='Shipping Type', related='sale_id.select_shipping_option', help="Shipping Type yang digunakan di SO",
+ tracking=3)
+ state_packing = fields.Selection([('not_packing', 'Belum Packing'), ('packing_done', 'Sudah Packing')],
+ string='Packing Status')
approval_invoice_date_id = fields.Many2one('approval.invoice.date', string='Approval Invoice Date')
last_update_date_doc_kirim = fields.Datetime(string='Last Update Tanggal Kirim', copy=False)
update_date_doc_kirim_add = fields.Boolean(string='Update Tanggal Kirim Lewat ADD')
@@ -295,7 +310,7 @@ class StockPicking(models.Model):
if not self.name or not self.origin:
return False
return f"{self.name}"
-
+
def _download_pod_photo(self, url):
"""Mengunduh foto POD dari URL"""
try:
@@ -304,7 +319,7 @@ class StockPicking(models.Model):
return base64.b64encode(response.content)
except Exception as e:
raise UserError(f"Gagal mengunduh foto POD: {str(e)}")
-
+
def _parse_datetime(self, dt_str):
"""Parse datetime string dari format KGX"""
try:
@@ -315,37 +330,37 @@ class StockPicking(models.Model):
return datetime.strptime(dt_str, '%Y-%m-%dT%H:%M:%S')
except ValueError:
return False
-
+
def action_get_kgx_pod(self, shipment=False):
self.ensure_one()
-
+
awb_number = shipment or self._get_kgx_awb_number()
if not awb_number:
raise UserError("Nomor AWB tidak dapat dibuat, pastikan picking memiliki name dan origin")
-
+
url = "https://kgx.co.id/get_detail_awb"
headers = {'Content-Type': 'application/json'}
- payload = {"params" : {'awb_number': awb_number}}
-
+ payload = {"params": {'awb_number': awb_number}}
+
try:
response = requests.post(url, headers=headers, data=json.dumps(payload))
response.raise_for_status()
data = response.json()
-
+
if data.get('result', {}).get('data', []):
pod_data = data['result']['data'][0].get('connote_pod', {})
photo_url = pod_data.get('photo')
-
+
self.kgx_pod_photo_url = photo_url
self.kgx_pod_signature = pod_data.get('signature')
self.kgx_pod_receiver = pod_data.get('receiver')
self.kgx_pod_receive_time = self._parse_datetime(pod_data.get('timeReceive'))
self.driver_arrival_date = self._parse_datetime(pod_data.get('timeReceive'))
-
+
return data
else:
raise UserError(f"Tidak ditemukan data untuk AWB: {awb_number}")
-
+
except requests.exceptions.RequestException as e:
raise UserError(f"Gagal mengambil data POD: {str(e)}")
@@ -692,8 +707,9 @@ class StockPicking(models.Model):
raise UserError(f"Order ini sudah dikirim ke Biteship. Dengan Tracking Id: {self.biteship_tracking_id}")
if self.sale_id.select_shipping_option == 'custom':
- raise UserError("Shipping Option pada Sales Order ini adalah *Custom*. Tidak dapat dikirim melalui Biteship.")
-
+ raise UserError(
+ "Shipping Option pada Sales Order ini adalah *Custom*. Tidak dapat dikirim melalui Biteship.")
+
def is_courier_need_coordinates(service_code):
return service_code in [
"instant", "same_day", "instant_car",
@@ -790,11 +806,11 @@ class StockPicking(models.Model):
self.message_post(
body=f"Biteship berhasil dilakukan.<br/>"
- f"Kurir: {self.carrier_id.name}<br/>"
- f"Tracking ID: {self.biteship_tracking_id or '-'}<br/>"
- f"Resi: {waybill_id or '-'}<br/>"
- f"Reference: {self.name}<br/>"
- f"SO: {self.sale_id.name}",
+ f"Kurir: {self.carrier_id.name}<br/>"
+ f"Tracking ID: {self.biteship_tracking_id or '-'}<br/>"
+ f"Resi: {waybill_id or '-'}<br/>"
+ f"Reference: {self.name}<br/>"
+ f"SO: {self.sale_id.name}",
message_type="comment"
)
@@ -936,6 +952,9 @@ class StockPicking(models.Model):
pending_section = None
# Invoice values.
invoice_vals = order._prepare_invoice()
+ invoice_date = self.date_done
+ invoice_vals['date'] = invoice_date
+ invoice_vals['invoice_date'] = invoice_date
# Invoice line values (keep only necessary sections).
for line in self.move_ids_without_package:
po_line = self.env['purchase.order.line'].search(
@@ -1307,7 +1326,6 @@ class StockPicking(models.Model):
)
)
-
self.validation_minus_onhand_quantity()
self.responsible = self.env.user.id
# self.send_koli_to_so()
@@ -1358,7 +1376,7 @@ class StockPicking(models.Model):
if 'BU/PUT' in self.name:
self.automatic_reserve_product()
return res
-
+
def automatic_reserve_product(self):
if self.state == 'done':
po = self.env['purchase.order'].search([
@@ -1376,11 +1394,12 @@ class StockPicking(models.Model):
continue
invoice = self.env['account.move'].search(
- [('sale_id', '=', picking.sale_id.id), ('state', 'not in', ['draft', 'cancel']), ('move_type', '=', 'out_invoice')], limit=1)
+ [('sale_id', '=', picking.sale_id.id), ('state', 'not in', ['draft', 'cancel']),
+ ('move_type', '=', 'out_invoice')], limit=1)
if not invoice:
continue
-
+
if not picking.so_lama and invoice and (not picking.date_doc_kirim or not invoice.invoice_date):
raise UserError("Tanggal Kirim atau Tanggal Invoice belum diisi!")
@@ -1664,7 +1683,7 @@ class StockPicking(models.Model):
self.ensure_one()
order = self.env['sale.order'].search([('name', '=', self.sale_id.name)], limit=1)
-
+
sale_order_delay = self.env['sale.order.delay'].search([('so_number', '=', order.name)], limit=1)
product_shipped = []
@@ -1679,11 +1698,12 @@ class StockPicking(models.Model):
'delivery_order': {
'name': self.name,
'carrier': self.carrier_id.name or '-',
- 'service' : order.delivery_service_type or '-',
+ 'service': order.delivery_service_type or '-',
'receiver_name': '',
'receiver_city': ''
},
- 'delivered_date': self.driver_departure_date.strftime('%d %b %Y') if self.driver_departure_date != False else '-',
+ 'delivered_date': self.driver_departure_date.strftime(
+ '%d %b %Y') if self.driver_departure_date != False else '-',
'delivered': False,
'status': self.shipping_status,
'waybill_number': self.delivery_tracking_no or '-',
@@ -1706,7 +1726,7 @@ class StockPicking(models.Model):
elif sale_order_delay.status == 'early':
day_start = day_start - sale_order_delay.days_delayed
day_end = day_end - sale_order_delay.days_delayed
-
+
eta_start = order.date_order + timedelta(days=day_start)
eta_end = order.date_order + timedelta(days=day_end)
formatted_eta = f"{eta_start.strftime('%d %b')} - {eta_end.strftime('%d %b %Y')}"
@@ -1735,7 +1755,7 @@ class StockPicking(models.Model):
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
-
+
manifests = []
try:
@@ -1743,15 +1763,15 @@ class StockPicking(models.Model):
response = requests.get(_biteship_url + '/trackings/' + self.biteship_tracking_id, headers=headers,
json=manifests)
result = response.json()
- description = {
- 'confirmed' : 'Indoteknik telah melakukan permintaan pick-up',
- 'allocated' : 'Kurir akan melakukan pick-up pesanan',
- 'picking_up' : 'Kurir sedang dalam perjalanan menuju lokasi pick-up',
- 'picked' : 'Pesanan sudah di pick-up kurir '+result.get("courier", {}).get("company", ""),
- 'on_hold' : 'Pesanan ditahan sementara karena masalah pengiriman',
- 'dropping_off' : 'Kurir sudah ditugaskan dan pesanan akan segera diantar ke pembeli',
- 'delivered' : f'Pesanan telah sampai dan diterima oleh <span style="color:#DC2626;">{result.get("destination", {}).get("contact_name", "")}</span>'
- }
+ # description = {
+ # 'confirmed' : 'Indoteknik telah melakukan permintaan pick-up',
+ # 'allocated' : 'Kurir akan melakukan pick-up pesanan',
+ # 'picking_up' : 'Kurir sedang dalam perjalanan menuju lokasi pick-up',
+ # 'picked' : 'Pesanan sudah di pick-up kurir '+result.get("courier", {}).get("company", ""),
+ # 'on_hold' : 'Pesanan ditahan sementara karena masalah pengiriman',
+ # 'dropping_off' : 'Kurir sudah ditugaskan dan pesanan akan segera diantar ke pembeli',
+ # 'delivered' : f'Pesanan telah sampai dan diterima oleh <span style="color:#DC2626;">{result.get("destination", {}).get("contact_name", "")}</span>'
+ # }
if (result.get('success') == True):
history = result.get("history", [])
status = result.get("status", "")
@@ -1760,7 +1780,7 @@ class StockPicking(models.Model):
manifests.append({
"status": re.sub(r'[^a-zA-Z0-9\s]', ' ', entry["status"]).lower().capitalize(),
"datetime": self._convert_to_local_time(entry["updated_at"]),
- "description": description[entry["status"]],
+ "description": self._get_biteship_status_description(entry["status"], result),
})
return {
@@ -1772,57 +1792,163 @@ class StockPicking(models.Model):
"manifests": [],
"delivered": False
}
- except Exception as e :
+ except Exception as e:
_logger.error(f"Error fetching Biteship order for picking {self.id}: {str(e)}")
- return { 'error': str(e) }
-
- def action_sync_biteship_tracking(self):
- for picking in self:
- if not picking.biteship_id:
- raise UserError("Tracking Biteship tidak tersedia.")
-
- histori = picking.get_manifest_biteship()
- updated_fields = {}
- seen_logs = set()
-
- manifests = sorted(histori.get("manifests", []), key=lambda m: m.get("datetime") or "")
-
- for manifest in manifests:
- status = manifest.get("status", "").lower()
- dt_str = manifest.get("datetime")
- desc = manifest.get("description")
- dt = False
-
- try:
- dt = picking._convert_to_utc_datetime(dt_str)
- _logger.info(f"[Biteship Sync] Berhasil parse datetime: {dt_str} -> {dt}")
- except Exception as e:
- _logger.warning(f"[Biteship Sync] Gagal parse datetime: {e}")
- continue
+ return {'error': str(e)}
+
+ # ACTION GET TRACKING MANUAL BITESHIP
+ # def action_sync_biteship_tracking(self):
+ # for picking in self:
+ # if not picking.biteship_id:
+ # raise UserError("Tracking Biteship tidak tersedia.")
+
+ # histori = picking.get_manifest_biteship()
+ # updated_fields = {}
+ # seen_logs = set()
+
+ # manifests = sorted(histori.get("manifests", []), key=lambda m: m.get("datetime") or "")
+
+ # for manifest in manifests:
+ # status = manifest.get("status", "").lower()
+ # dt_str = manifest.get("datetime")
+ # desc = manifest.get("description")
+ # dt = False
+
+ # try:
+ # dt = picking._convert_to_utc_datetime(dt_str)
+ # _logger.info(f"[Biteship Sync] Berhasil parse datetime: {dt_str} -> {dt}")
+ # except Exception as e:
+ # _logger.warning(f"[Biteship Sync] Gagal parse datetime: {e}")
+ # continue
+
+ # # Update tanggal ke field (pastikan naive datetime UTC)
+ # if status == "picked" and dt and not picking.driver_departure_date:
+ # updated_fields["driver_departure_date"] = fields.Datetime.to_string(dt)
+
+ # if status == "delivered" and dt and not picking.driver_arrival_date:
+ # updated_fields["driver_arrival_date"] = fields.Datetime.to_string(dt)
+
+ # # Buat log unik dengan waktu lokal Asia/Jakarta
+ # if dt and desc:
+ # try:
+ # dt_local = parser.parse(dt_str).replace(tzinfo=None)
+ # except Exception as e:
+ # _logger.warning(f"[Biteship Sync] Gagal parse dt_str untuk log: {e}")
+ # dt_local = dt # fallback
+
+ # desc_clean = ' '.join(desc.strip().split())
+ # log_line = f"[TRACKING] {status} - {dt_local.strftime('%d %b %Y %H:%M')}: {desc_clean}"
+ # if not picking._has_existing_log(log_line):
+ # picking.message_post(body=log_line)
+ # seen_logs.add(log_line)
+
+ # if updated_fields:
+ # picking.write(updated_fields)
+
+ def action_open_biteship_tracking(self):
+ self.ensure_one()
+ if not self.biteship_courier_link:
+ raise UserError("Biteship tracking link tidak tersedia.")
+ return {
+ 'type': 'ir.actions.act_url',
+ 'url': self.biteship_courier_link,
+ 'target': 'new',
+ }
- # Update tanggal ke field (pastikan naive datetime UTC)
- if status == "picked" and dt and not picking.driver_departure_date:
- updated_fields["driver_departure_date"] = fields.Datetime.to_string(dt)
+ def _get_biteship_status_description(self, status, data=None):
+ data = data or {}
+ courier = data.get("courier", {}).get("company", "")
+ contact_name = data.get("destination", {}).get("contact_name", "")
+
+ description_map = {
+ 'confirmed': 'Indoteknik telah melakukan permintaan pick-up',
+ 'allocated': 'Kurir akan melakukan pick-up pesanan',
+ 'picking_up': 'Kurir sedang dalam perjalanan menuju lokasi pick-up',
+ 'picked': f'Pesanan sudah di pick-up kurir {courier}',
+ 'dropping_off': 'Kurir sudah ditugaskan dan pesanan akan segera diantar ke pembeli',
+ 'delivered': f'Pesanan telah sampai dan diterima oleh <span style="color:#DC2626;">{contact_name}</span>',
+ 'return_in_transit': 'Pesanan dalam perjalanan kembali ke pengirim',
+ 'on_hold': 'Pesanan ditahan sementara karena masalah pengiriman',
+ 'rejected': 'Pesanan ditolak, silakan hubungi Biteship',
+ 'courier_not_found': 'Pesanan dibatalkan karena tidak ada kurir tersedia',
+ 'returned': 'Pesanan berhasil dikembalikan',
+ 'disposed': 'Pesanan sudah dimusnahkan',
+ 'cancelled': 'Pesanan dibatalkan oleh sistem atau pengguna',
+ }
- if status == "delivered" and dt and not picking.driver_arrival_date:
- updated_fields["driver_arrival_date"] = fields.Datetime.to_string(dt)
+ return description_map.get(status, f"Status '{status}' diterima dari Biteship")
- # Buat log unik dengan waktu lokal Asia/Jakarta
- if dt and desc:
- try:
- dt_local = parser.parse(dt_str).replace(tzinfo=None)
- except Exception as e:
- _logger.warning(f"[Biteship Sync] Gagal parse dt_str untuk log: {e}")
- dt_local = dt # fallback
+ def log_biteship_event_from_webhook(self, status, timestamp, description, extra_data=None):
+ self.ensure_one()
+ updated_fields = {}
- desc_clean = ' '.join(desc.strip().split())
- log_line = f"[TRACKING] {status} - {dt_local.strftime('%d %b %Y %H:%M')}: {desc_clean}"
- if not picking._has_existing_log(log_line):
- picking.message_post(body=log_line)
- seen_logs.add(log_line)
+ try:
+ dt = self._convert_to_utc_datetime(timestamp)
+ except Exception as e:
+ _logger.warning(f"[Webhook] Gagal konversi waktu: {e}")
+ dt = datetime.utcnow()
+
+ # Penanganan status pengiriman
+ if status == "picked" and not self.driver_departure_date:
+ updated_fields["driver_departure_date"] = fields.Datetime.to_string(dt)
+ if status == "delivered" and not self.driver_arrival_date:
+ updated_fields["driver_arrival_date"] = fields.Datetime.to_string(dt)
+
+ shipping_status = self._map_status_biteship(status)
+ if shipping_status and self.shipping_status != shipping_status:
+ updated_fields["shipping_status"] = shipping_status
+
+ # Penanganan extra data dari webhook
+ if extra_data:
+ # Informasi kurir
+ if extra_data.get("courier_driver_name"):
+ updated_fields["biteship_driver_name"] = extra_data["courier_driver_name"]
+ if extra_data.get("courier_driver_phone"):
+ updated_fields["biteship_driver_phone"] = extra_data["courier_driver_phone"]
+ if extra_data.get("courier_driver_plate_number"):
+ updated_fields["biteship_driver_plate_number"] = extra_data["courier_driver_plate_number"]
+ if extra_data.get("courier_link"):
+ updated_fields["biteship_courier_link"] = extra_data["courier_link"]
+ # Informasi harga
+ if extra_data.get("order_price"):
+ updated_fields["biteship_shipping_price"] = extra_data["order_price"]
+ # Status mentah dari Biteship
+ if extra_data.get("status"):
+ updated_fields["biteship_shipping_status"] = extra_data["status"]
+
+ # Tambahan untuk handle order.waybill_id
+ if extra_data.get("tracking_id"):
+ updated_fields["biteship_tracking_id"] = extra_data["tracking_id"]
+ updated_fields["delivery_tracking_no"] = extra_data["tracking_id"]
+ if extra_data.get("waybill_id"):
+ updated_fields["biteship_waybill_id"] = extra_data["waybill_id"]
+
+ # Konversi waktu lokal untuk log
+ try:
+ dt_parsed = parser.parse(timestamp)
+ if dt_parsed.tzinfo is None:
+ dt_parsed = dt_parsed.replace(tzinfo=pytz.utc)
+ dt_local = dt_parsed.astimezone(pytz.timezone("Asia/Jakarta"))
+ except Exception:
+ dt_local = dt.astimezone(pytz.timezone("Asia/Jakarta"))
+
+ # Format pesan log
+ desc_clean = ' '.join(description.strip().split())
+ log_line = f"[TRACKING] {status} - {dt_local.strftime('%d %b %Y %H:%M')}:<br/>{desc_clean}"
+
+ # Hindari log duplikat
+ if not self._has_existing_log(log_line):
+ biteship_user = self.env['res.users'].sudo().browse(15710) # ID live
+ # biteship_user = self.env['res.users'].sudo().browse(15710) # ID user (cek di db)
+ self.sudo().message_post(
+ body=log_line,
+ author_id=biteship_user.partner_id.id
+ )
- if updated_fields:
- picking.write(updated_fields)
+ # Update field-field terkait
+ if updated_fields:
+ self.write(updated_fields)
+ _logger.info(f"[Webhook] Updated fields on picking {self.name}: {updated_fields}")
def _has_existing_log(self, log_line):
self.ensure_one()
@@ -1872,10 +1998,15 @@ class StockPicking(models.Model):
"allocated": "pending",
"picking_up": "pending",
"picked": "shipment",
- "cancelled": "cancelled",
- "on_hold": "on_hold",
"dropping_off": "shipment",
- "delivered": "completed"
+ "delivered": "completed",
+ "return_in_transit": "returning",
+ "on_hold": "on_hold",
+ "rejected": "cancelled",
+ "courier_not_found": "cancelled",
+ "returned": "returned",
+ "disposed": "disposed",
+ "cancelled": "cancelled"
}
return status_mapping.get(status, "Hubungi Admin")
@@ -1885,8 +2016,7 @@ class StockPicking(models.Model):
days_end = self.sale_id.estimated_arrival_days or (self.sale_id.estimated_arrival_days + 3)
start_date = self.sale_id.create_date + datetime.timedelta(days=days_start)
end_date = self.sale_id.create_date + datetime.timedelta(days=days_end)
-
-
+
add_day_start = 0
add_day_end = 0
sale_order_delay = self.env['sale.order.delay'].search([('so_number', '=', self.sale_id.name)], limit=1)
@@ -1895,11 +2025,11 @@ class StockPicking(models.Model):
add_day_start = sale_order_delay.days_delayed
add_day_end = sale_order_delay.days_delayed
elif sale_order_delay.status == 'early':
- add_day_start = -abs(sale_order_delay.days_delayed)
- add_day_end = -abs(sale_order_delay.days_delayed)
-
- fastest_eta = start_date +datetime.timedelta(days=add_day_start + add_day_start)
-
+ add_day_start = -abs(sale_order_delay.days_delayed)
+ add_day_end = -abs(sale_order_delay.days_delayed)
+
+ fastest_eta = start_date + datetime.timedelta(days=add_day_start + add_day_start)
+
longest_eta = end_date + datetime.timedelta(days=add_day_end)
format_time = '%d %b %Y'