summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMiqdad <ahmadmiqdad27@gmail.com>2025-07-14 13:09:29 +0700
committerMiqdad <ahmadmiqdad27@gmail.com>2025-07-14 13:09:29 +0700
commit6bd30cc76c8d822840a6063959bfe50c6625fd12 (patch)
tree890fd4b46c8697fdb4763e4fbdcbc47a9713de9c
parent717dbfa7070e94c0af2bb39e2cebb4dc71d123b9 (diff)
parentb70acc8a458e9544f672ecc9549eb1fc3606bc35 (diff)
<miqdad> merge
-rw-r--r--indoteknik_api/controllers/api_v1/stock_picking.py140
-rwxr-xr-xindoteknik_custom/__manifest__.py2
-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
-rwxr-xr-xindoteknik_custom/security/ir.model.access.csv2
-rw-r--r--indoteknik_custom/views/account_move.xml1
-rw-r--r--indoteknik_custom/views/approval_payment_term.xml94
-rw-r--r--indoteknik_custom/views/customer_commision.xml1
-rw-r--r--indoteknik_custom/views/ir_sequence.xml10
-rw-r--r--indoteknik_custom/views/mail_template_invoice_reminder.xml51
-rw-r--r--indoteknik_custom/views/res_partner.xml19
-rw-r--r--indoteknik_custom/views/stock_picking.xml22
21 files changed, 893 insertions, 179 deletions
diff --git a/indoteknik_api/controllers/api_v1/stock_picking.py b/indoteknik_api/controllers/api_v1/stock_picking.py
index c5a4f7ed..85b0fbba 100644
--- a/indoteknik_api/controllers/api_v1/stock_picking.py
+++ b/indoteknik_api/controllers/api_v1/stock_picking.py
@@ -1,10 +1,14 @@
from .. import controller
from odoo import http
-from odoo.http import request
+from odoo.http import request, Response
from pytz import timezone
from datetime import datetime
import json
+import logging
+_logger = logging.getLogger(__name__)
+
+_logger = logging.getLogger(__name__)
class StockPicking(controller.Controller):
prefix = '/api/v1/'
@@ -143,54 +147,104 @@ class StockPicking(controller.Controller):
'name': picking_data.name
})
- # @http.route(prefix + 'webhook/biteship', type='json', auth='public', methods=['POST'], csrf=False)
- # def udpate_status_from_bitehsip(self, **kw):
- # try:
- # if not request.jsonrequest:
- # return "ok"
+ @http.route(prefix + 'webhook/biteship', type='json', auth='public', methods=['POST'], csrf=False)
+ def update_status_from_biteship(self, **kw):
+ _logger.info("Biteship Webhook: Request received at controller start (type='json').")
+
+ try:
+ # Karena type='json', Odoo secara otomatis akan mem-parsing JSON untuk Anda.
+ # 'data' akan berisi dictionary Python dari payload JSON Biteship.
+ data = request.jsonrequest
+
+ # Log ini akan menunjukkan payload yang diterima (sudah dalam bentuk dict)
+ _logger.info(f"Biteship Webhook: Parsed JSON data from request.jsonrequest: {json.dumps(data)}")
+
+ event = data.get('event')
+ if event:
+ _logger.info(f"Biteship Webhook: Processing event: {event}")
+ if event == "order.status":
+ self.process_order_status(data)
+ elif event == "order.price":
+ self.process_order_price(data)
+ elif event == "order.waybill_id":
+ self.process_order_waybill(data)
+ # Tambahkan logika untuk event lain jika ada
+ else:
+ _logger.info("Biteship Webhook: No specific event in payload. Likely an installation/verification ping or unknown event type.")
+
+ # Untuk route type='json', Anda cukup mengembalikan dictionary Python.
+ # Odoo akan secara otomatis mengonversinya menjadi respons JSON yang valid.
+ return {'status': 'ok'}
+
+ except Exception as e:
+ _logger.error(f"Biteship Webhook: Unhandled error during processing: {e}", exc_info=True)
+ # Untuk error, kembalikan dictionary error juga, Odoo akan mengonversinya ke JSON
+ return {'status': 'error', 'message': str(e)}
- # data = request.jsonrequest # Ambil data JSON dari request
- # event = data.get('event')
+ def process_order_status(self, data):
+ picking = request.env['stock.picking'].sudo().search([
+ ('biteship_id', '=', data.get('order_id'))
+ ], limit=1)
- # # Handle Event Berdasarkan Jenisnya
- # if event == "order.status":
- # self.process_order_status(data)
- # elif event == "order.price":
- # self.process_order_price(data)
- # elif event == "order.waybill_id":
- # self.process_order_waybill(data)
+ if not picking:
+ _logger.warning(f"[Webhook] Tidak ditemukan picking untuk order_id {data.get('order_id')}")
+ return
- # return {'success': True, 'message': f'Webhook {event} received'}
- # except Exception as e:
- # return {'success': False, 'message': str(e)}
+ status = data.get('status')
+ timestamp = data.get('updated_at') or datetime.utcnow().isoformat()
+
+ description = picking._get_biteship_status_description(status, {
+ "courier": {"company": data.get("courier_company", "")},
+ "destination": {"contact_name": picking.partner_id.name or ""}
+ })
+
+ # Tambahkan extra data dari webhook
+ extra_data = {
+ "courier_driver_name": data.get("courier_driver_name"),
+ "courier_driver_phone": data.get("courier_driver_phone"),
+ "courier_driver_plate_number": data.get("courier_driver_plate_number"),
+ "courier_link": data.get("courier_link"),
+ "order_price": data.get("order_price"),
+ "status": data.get("status"),
+ }
+
+ picking.log_biteship_event_from_webhook(status, timestamp, description, extra_data=extra_data)
- # @http.route(prefix + 'webhook/biteship', auth='public', methods=['POST'], csrf=False)
- # def udpate_status_from_bitehsip(self, **kw):
- # return "ok"
- def process_order_status(self, data):
- picking_model = request.env['stock.picking'].sudo().search([('biteship_id', '=', data.get('order_id'))],
- limit=1)
- if data.get('status') == 'picked':
- picking_model.write({'driver_departure_date': datetime.utcnow()})
- elif data.get('status') == 'delivered':
- picking_model.write({'driver_arrival_date': datetime.utcnow()})
def process_order_price(self, data):
- picking_model = request.env['stock.picking'].sudo().search([('biteship_id', '=', data.get('order_id'))],
- limit=1)
- order = request.env['sale.order'].sudo().search([('name', '=', picking_model.sale_id.name)], limit=1)
- if order:
- order.write({
- 'delivery_amt': data.get('price')
- })
+ picking = request.env['stock.picking'].sudo().search([('biteship_id', '=', data.get('order_id'))], limit=1)
+
+ if not picking:
+ _logger.warning(f"Tidak ditemukan picking untuk order_id {data.get('order_id')}")
+ return
+
+ picking.log_biteship_event_from_webhook(
+ status='order.price',
+ timestamp=data.get('updated_at') or datetime.utcnow().isoformat(),
+ description='Biaya pengiriman telah diperbarui berdasarkan informasi terbaru dari Biteship.',
+ extra_data={
+ "order_price": data.get("price")
+ }
+ )
+
def process_order_waybill(self, data):
- picking_model = request.env['stock.picking'].sudo().search([('biteship_id', '=', data.get('order_id'))],
- limit=1)
- if picking_model:
- picking_model.write({
- 'biteship_waybill_id': data.get('courier_waybill_id'),
- 'delivery_tracking_no': data.get('courier_waybill_id'),
- 'biteship_tracking_id': data.get('courier_tracking_id')
- })
+ picking = request.env['stock.picking'].sudo().search([
+ ('biteship_id', '=', data.get('order_id'))
+ ], limit=1)
+
+ if not picking:
+ _logger.warning(f"Tidak ditemukan picking untuk order_id {data.get('order_id')}")
+ return
+
+ picking.log_biteship_event_from_webhook(
+ status='order.waybill_id',
+ timestamp=data.get('updated_at') or datetime.utcnow().isoformat(),
+ description="Nomor waybill dan tracking diperbarui melalui Biteship.",
+ extra_data={
+ "tracking_id": data.get("courier_tracking_id"),
+ "waybill_id": data.get("courier_waybill_id")
+ }
+ )
+
diff --git a/indoteknik_custom/__manifest__.py b/indoteknik_custom/__manifest__.py
index cf5556d1..2af13816 100755
--- a/indoteknik_custom/__manifest__.py
+++ b/indoteknik_custom/__manifest__.py
@@ -97,6 +97,7 @@
'views/mail_template_po.xml',
'views/mail_template_efaktur.xml',
'views/mail_template_invoice_po.xml',
+ 'views/mail_template_invoice_reminder.xml',
'views/price_group.xml',
'views/mrp_production.xml',
'views/apache_solr.xml',
@@ -156,6 +157,7 @@
'views/stock_backorder_confirmation_views.xml',
'views/barcoding_product.xml',
'views/project_views.xml',
+ 'views/approval_payment_term.xml',
'report/report.xml',
'report/report_banner_banner.xml',
'report/report_banner_banner2.xml',
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'
diff --git a/indoteknik_custom/security/ir.model.access.csv b/indoteknik_custom/security/ir.model.access.csv
index c2b0895d..85781524 100755
--- a/indoteknik_custom/security/ir.model.access.csv
+++ b/indoteknik_custom/security/ir.model.access.csv
@@ -182,6 +182,8 @@ access_sale_order_delay,sale.order.delay,model_sale_order_delay,,1,1,1,1
access_production_purchase_match,access.production.purchase.match,model_production_purchase_match,,1,1,1,1
access_image_carousel,access.image.carousel,model_image_carousel,,1,1,1,1
access_v_sale_notin_matchpo,access.v.sale.notin.matchpo,model_v_sale_notin_matchpo,,1,1,1,1
+access_approval_payment_term,access.approval.payment.term,model_approval_payment_term,,1,1,1,1
+
access_tukar_guling_all_users,tukar.guling.all.users,model_tukar_guling,base.group_user,1,1,1,1
access_tukar_guling_line_all_users,tukar.guling.line.all.users,model_tukar_guling_line,base.group_user,1,1,1,1
access_tukar_guling_po_all_users,tukar.guling.po.all.users,model_tukar_guling_po,base.group_user,1,1,1,1
diff --git a/indoteknik_custom/views/account_move.xml b/indoteknik_custom/views/account_move.xml
index ad52a74a..2f52b3d9 100644
--- a/indoteknik_custom/views/account_move.xml
+++ b/indoteknik_custom/views/account_move.xml
@@ -70,6 +70,7 @@
<field name="so_shipping_covered_by"/>
<field name="so_delivery_amt"/>
<field name="flag_delivery_amt"/>
+ <field name="length_of_payment"/>
</field>
<field name="amount_untaxed" position="after">
<field name="other_subtotal" invisible="1"/>
diff --git a/indoteknik_custom/views/approval_payment_term.xml b/indoteknik_custom/views/approval_payment_term.xml
new file mode 100644
index 00000000..cc9db914
--- /dev/null
+++ b/indoteknik_custom/views/approval_payment_term.xml
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<odoo>
+ <record id="approval_payment_term_tree" model="ir.ui.view">
+ <field name="name">approval.payment.term.tree</field>
+ <field name="model">approval.payment.term</field>
+ <field name="arch" type="xml">
+ <tree default_order="create_date desc">
+ <field name="number"/>
+ <field name="partner_id"/>
+ <field name="parent_id"/>
+ <field name="property_payment_term_id"/>
+ <field name="create_date" optional="hide"/>
+ <field name="approve_date" optional="hide"/>
+ <field name="approve_sales_manager" optional="hide"/>
+ <field name="approve_finance" optional="hide"/>
+ <field name="approve_leader" optional="hide"/>
+ <field name="create_uid" optional="hide"/>
+ <field name="sale_order_ids" optional="hide" widget="many2many_tags"/>
+ <field name="total" optional="hide"/>
+ <field name="grand_total" optional="hide"/>
+ <field name="state" widget="badge" decoration-danger="state == 'rejected'"
+ decoration-success="state == 'approved'"
+ decoration-info="state in ['waiting_approval_sales_manager', 'waiting_approval_finance', 'waiting_approval_leader']"/>
+ <field name="change_log_688" optional="hide"/>
+ </tree>
+ </field>
+ </record>
+
+ <record id="approval_payment_term_form" model="ir.ui.view">
+ <field name="name">approval.payment.term.form</field>
+ <field name="model">approval.payment.term</field>
+ <field name="arch" type="xml">
+ <form>
+ <header>
+ <button name="button_approve"
+ string="Approve"
+ type="object"
+ attrs="{'invisible': [('approve_leader', '=', True)]}"
+ />
+ <button name="button_reject"
+ string="Reject"
+ type="object"
+ attrs="{'invisible': [('approve_leader', '=', True)]}"
+ />
+ <field name="state" widget="statusbar"
+ statusbar_visible="waiting_approval,approved,rejected"
+ statusbar_colors='{"rejected":"red"}'/>
+ </header>
+ <sheet string="Approval Payment Term">
+ <group>
+ <group>
+ <field name="number" readonly="1"/>
+ <field name="partner_id"/>
+ <field name="parent_id" readonly="1"/>
+ <field name="blocking_stage" attrs="{'readonly': ['|', ('number', '=', False), ('state', 'in', ['approved','rejected'])]}"/>
+ <field name="warning_stage" attrs="{'readonly': ['|', ('number', '=', False), ('state', 'in', ['approved','rejected'])]}"/>
+ <field name="property_payment_term_id" attrs="{'readonly': ['|', ('number', '=', False), ('state', 'in', ['approved','rejected'])]}"/>
+ <field name="active_limit" attrs="{'readonly': ['|', ('number', '=', False), ('state', 'in', ['approved','rejected'])]}"/>
+ </group>
+ <group>
+ <field name="reason"/>
+ <field name="reason_reject" attrs="{'invisible': [('state', '!=', 'rejected')]}"/>
+ <field name="approve_date" readonly="1"/>
+ <field name="approve_sales_manager" readonly="1"/>
+ <field name="approve_finance" readonly="1"/>
+ <field name="approve_leader" readonly="1"/>
+ <field name="sale_order_ids" widget="many2many_tags"/>
+ <field name="total"/>
+ <field name="grand_total"/>
+ </group>
+ </group>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids" widget="mail_followers"/>
+ <field name="message_ids" widget="mail_thread"/>
+ </div>
+ </form>
+ </field>
+ </record>
+
+ <record id="approval_payment_term_action" model="ir.actions.act_window">
+ <field name="name">Approval Payment Term</field>
+ <field name="type">ir.actions.act_window</field>
+ <field name="res_model">approval.payment.term</field>
+ <field name="view_mode">tree,form</field>
+ </record>
+
+ <menuitem id="menu_approval_payment_term" name="Approval Payment Term"
+ parent="account.menu_finance_receivables"
+ action="approval_payment_term_action"
+ sequence="100"
+ />
+
+</odoo> \ No newline at end of file
diff --git a/indoteknik_custom/views/customer_commision.xml b/indoteknik_custom/views/customer_commision.xml
index 1c17bf63..514e6284 100644
--- a/indoteknik_custom/views/customer_commision.xml
+++ b/indoteknik_custom/views/customer_commision.xml
@@ -103,6 +103,7 @@
<field name="notification" readonly="1"/>
<!-- <field name="status" readonly="1"/>-->
<field name="payment_status" readonly="1"/>
+ <field name="biaya_lain_lain"/>
<field name="total_dpp"/>
</group>
</group>
diff --git a/indoteknik_custom/views/ir_sequence.xml b/indoteknik_custom/views/ir_sequence.xml
index 8ae532cc..4da9a8bc 100644
--- a/indoteknik_custom/views/ir_sequence.xml
+++ b/indoteknik_custom/views/ir_sequence.xml
@@ -150,6 +150,16 @@
<field name="number_increment">1</field>
<field name="active">True</field>
</record>
+
+ <record id="sequence_approval_payment_term" model="ir.sequence">
+ <field name="name">Approval Payment Term</field>
+ <field name="code">approval.payment.term</field>
+ <field name="prefix">APT/%(year)s/</field>
+ <field name="padding">5</field>
+ <field name="number_next">1</field>
+ <field name="number_increment">1</field>
+ <field name="active">True</field>
+ </record>
<record id="sequence_commision_fee" model="ir.sequence">
<field name="name">Customer Commision Fee</field>
diff --git a/indoteknik_custom/views/mail_template_invoice_reminder.xml b/indoteknik_custom/views/mail_template_invoice_reminder.xml
new file mode 100644
index 00000000..21055eb0
--- /dev/null
+++ b/indoteknik_custom/views/mail_template_invoice_reminder.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<odoo>
+ <data noupdate="0">
+ <record id="mail_template_invoice_due_reminder" model="mail.template">
+ <field name="name">Invoice Reminder: Due Date Notification</field>
+ <field name="model_id" ref="account.model_account_move"/>
+ <field name="subject">Reminder Invoice Due - ${object.name}</field>
+ <field name="email_from">finance@indoteknik.co.id</field>
+ <field name="email_to">andrifebriyadiputra@gmail.com</field>
+ <field name="body_html" type="html">
+ <div>
+ <p><b>Dear ${object.name},</b></p>
+
+ <p>Berikut adalah daftar invoice Anda yang mendekati atau telah jatuh tempo:</p>
+
+ <table border="1" cellpadding="4" cellspacing="0" style="border-collapse: collapse; width: 100%; font-size: 12px">
+ <thead>
+ <tr style="background-color: #f2f2f2;" align="left">
+ <th>Invoice Number</th>
+ <th>Tanggal Invoice</th>
+ <th>Jatuh Tempo</th>
+ <th>Sisa Hari</th>
+ <th>Total</th>
+ <th>Referensi</th>
+ </tr>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+
+ <p>Mohon bantuan dan kerjasamanya agar tetap bisa bekerjasama dengan baik</p>
+ <p>Terima Kasih.</p>
+ <br/>
+ <br/>
+ <p><b>Best Regards,
+ <br/>
+ <br/>
+ Widya R.<br/>
+ Dept. Finance<br/>
+ PT. INDOTEKNIK DOTCOM GEMILANG<br/>
+ <img src="https://erp.indoteknik.com/api/image/ir.attachment/datas/2135765" alt="Indoteknik" style="max-width: 18%; height: auto;"></img><br/>
+ <a href="https://wa.me/6285716970374" target="_blank">+62-857-1697-0374</a> |
+ <a href="mailto:finance@indoteknik.co.id">finance@indoteknik.co.id</a>
+ </b></p>
+
+ </div>
+ </field>
+ <field name="auto_delete" eval="True"/>
+ </record>
+ </data>
+</odoo>
diff --git a/indoteknik_custom/views/res_partner.xml b/indoteknik_custom/views/res_partner.xml
index ac4d0364..a030a75c 100644
--- a/indoteknik_custom/views/res_partner.xml
+++ b/indoteknik_custom/views/res_partner.xml
@@ -108,13 +108,6 @@
<xpath expr="//field[@name='property_supplier_payment_term_id']" position="attributes">
<attribute name="readonly">1</attribute>
</xpath>
- <xpath expr="//notebook/page[@name='accounting']" position="inside">
- <group string="Aging Info">
- <field name="avg_aging" readonly="1"/>
- <field name="payment_difficulty" attrs="{'readonly': [('parent_id', '!=', False)]}" />
- <field name="payment_history_url" readonly="1" />
- </group>
- </xpath>
<notebook>
<page string="Pengajuan Tempo">
<!-- Informasi Usaha Section -->
@@ -188,11 +181,6 @@
<field name="dokumen_pengiriman_input"/>
<field name="dokumen_invoice"/>
</group>
- <!-- <group string="Aging Info">
- <field name="avg_aging" readonly="1"/>
- <field name="payment_difficulty" attrs="{'readonly': [('parent_id', '!=', False)]}" />
- <field name="payment_history_url" readonly="1" />
- </group> -->
</group>
<!-- Supplier Lines Section -->
@@ -225,6 +213,13 @@
<field name="dokumen_ktp_dirut" />
</group>
</page>
+ <page string="Aging Info">
+ <group string="Aging Info">
+ <field name="avg_aging" readonly="1"/>
+ <field name="payment_difficulty" attrs="{'readonly': [('parent_id', '!=', False)]}" />
+ <field name="payment_history_url" readonly="1" />
+ </group>
+ </page>
</notebook>
</field>
</record>
diff --git a/indoteknik_custom/views/stock_picking.xml b/indoteknik_custom/views/stock_picking.xml
index f4159b1b..f9200dfa 100644
--- a/indoteknik_custom/views/stock_picking.xml
+++ b/indoteknik_custom/views/stock_picking.xml
@@ -64,12 +64,12 @@
string="Biteship"
type="object"
/>
- <button name="action_sync_biteship_tracking"
+ <!-- <button name="action_sync_biteship_tracking"
type="object"
string="Lacak dari Biteship"
class="btn-primary"
attrs="{'invisible': [('biteship_id', '=', False)]}"
- />
+ /> -->
<button name="track_envio_shipment"
string="Tracking Envio"
type="object"
@@ -192,19 +192,33 @@
<field name="note_logistic"/>
<field name="note_info"/>
<field name="responsible" />
- <field name="carrier_id"/>
+ <field name="carrier_id" attrs="{'invisible': [('select_shipping_option_so', '=', 'biteship')]}" />
<field name="biteship_id" invisible="1"/>
<field name="out_code" attrs="{'invisible': [['out_code', '=', False]]}"/>
<field name="picking_code" attrs="{'invisible': [['picking_code', '=', False]]}"/>
<field name="picking_code" string="Picking code (akan digenerate ketika sudah di-validate)" attrs="{'invisible': [['picking_code', '!=', False]]}"/>
<field name="driver_departure_date" attrs="{'readonly':[('invoice_status', '=', 'invoiced')]}"/>
<field name="driver_arrival_date"/>
- <field name="delivery_tracking_no"/>
+ <field name="delivery_tracking_no" attrs="{'invisible': [('select_shipping_option_so', '=', 'biteship')]}"/>
<field name="driver_id"/>
<field name='sj_return_date'/>
<field name="sj_documentation" widget="image" />
<field name="paket_documentation" widget="image" />
</group>
+ <!-- Biteship Group -->
+ <group attrs="{'invisible': [('select_shipping_option_so', '!=', 'biteship')]}">
+ <field name="delivery_tracking_no" />
+ <field name="shipping_method_so_id"/>
+ <field name="shipping_option_so_id"/>
+ <field name="biteship_shipping_price" readonly="1"/>
+ <field name="currency_id" invisible="1"/>
+ <field name="biteship_shipping_status" readonly="1"/>
+ <field name="biteship_driver_name" readonly="1"/>
+ <field name="biteship_driver_phone" readonly="1"/>
+ <field name="biteship_driver_plate_number" readonly="1"/>
+ <button name="action_open_biteship_tracking" string="Visit Biteship Tracking" type="object"/>
+ </group>
+
<group attrs="{'invisible': [('carrier_id', '!=', 151)]}">
<field name="envio_id" invisible="1"/>
<field name="envio_code"/>