from shutil import copy
from odoo import fields, models, api, _
from odoo.exceptions import AccessError, UserError, ValidationError
from dateutil.relativedelta import relativedelta
from datetime import datetime, timedelta
import logging
from pytz import timezone, utc
import io
import requests
import json
import base64
try:
from odoo.tools.misc import xlsxwriter
except ImportError:
import xlsxwriter
_logger = logging.getLogger(__name__)
class PurchaseOrder(models.Model):
_inherit = 'purchase.order'
automatic_purchase_id = fields.Many2one(
'automatic.purchase',
string='Automatic Purchase Reference',
ondelete='set null',
index=True,
copy=False
)
sale_order_id = fields.Many2one('sale.order', string='Sales Order', copy=False)
move_entry_id = fields.Many2one('account.move', string='Journal Entries', copy=False)
amount_discount = fields.Monetary(
string='Total Discount',
compute='_compute_amount_discount',
store=True
)
biaya_lain_lain = fields.Float(
'Biaya Lain Lain',
default=0.0,
tracking=True,
copy=False
)
source = fields.Selection([
('requisition', 'Requisition'),
('reordering', 'Reordering'),
('purchasing_job', 'Purchasing Job'),
('manual', 'Manual')
], string='Source', default='manual', copy=False)
count_journals = fields.Integer('Count Payment', compute='_compute_count_journals')
shipping_cost = fields.Float(string='Shipping Cost', help='Total shipping cost for this PO')
order_altama_id = fields.Integer('Req Order Altama', copy=False)
soo_number = fields.Char('SOO Number', copy=False)
soo_price = fields.Float('SOO Price', copy=False)
soo_discount = fields.Float('SOO Discount', copy=False)
soo_tax = fields.Float('SOO Tax', copy=False)
discount_total = fields.Float('Discount Total', help = 'Total Discount for Each Product', copy=False, default=0.0)
bill_date = fields.Date('Bill Date', copy=False)
uangmuka_exist = fields.Boolean('Uang Muka Exist', copy=False)
def _prepare_invoice(self):
"""Prepare the dict of values to create the new invoice for a purchase order.
"""
self.ensure_one()
move_type = self._context.get('default_move_type', 'in_invoice')
journal = self.env['account.move'].with_context(default_move_type=move_type)._get_default_journal()
if not journal:
raise UserError(_('Please define an accounting purchase journal for the company %s (%s).') % (self.company_id.name, self.company_id.id))
partner_invoice_id = self.partner_id.address_get(['invoice'])['invoice']
partner_bank_id = self.partner_id.commercial_partner_id.bank_ids.filtered_domain(['|', ('company_id', '=', False), ('company_id', '=', self.company_id.id)])[:1]
invoice_vals = {
'ref': self.partner_ref or '',
'move_type': move_type,
'invoice_date': self.bill_date or fields.Date.today(),
'purchase_order_id': self.id,
'narration': self.notes,
'currency_id': self.currency_id.id,
'invoice_user_id': self.user_id and self.user_id.id or self.env.user.id,
'partner_id': partner_invoice_id,
'fiscal_position_id': (self.fiscal_position_id or self.fiscal_position_id.get_fiscal_position(partner_invoice_id)).id,
'payment_reference': self.partner_ref or '',
'partner_bank_id': partner_bank_id.id,
'invoice_origin': self.name,
'invoice_payment_term_id': self.payment_term_id.id,
'invoice_line_ids': [],
'company_id': self.company_id.id,
}
return invoice_vals
@api.constrains('invoice_ids')
def _auto_action_post_bills(self):
for bill in self.invoice_ids:
if bill.state == 'draft':
bill.action_post()
def open_form_multi_create_bills(self):
return {
'name': _('Create Bills'),
'type': 'ir.actions.act_window',
'res_model': 'purchase.order.multi_bills',
'view_mode': 'form',
'target': 'new',
'context': {
'po_ids': self.ids,
}
}
def _get_fixco_token(self, source='auto'):
ICP = self.env['ir.config_parameter'].sudo()
TokenLog = self.env['token.log'].sudo()
token_url = ICP.get_param('token.adempiere.altama')
client_id = ICP.get_param('client.adempiere.altama')
client_secret = ICP.get_param('secret_key.adempiere.altama')
active_token = TokenLog.search([
('is_active', '=', True),
('token_from', '=', 'Adempiere Altama'),
('expires_at', '>', datetime.utcnow()),
], limit=1, order='id desc')
if active_token:
return active_token.token
headers = {
"Authorization": "Basic " + base64.b64encode(f"{client_id}:{client_secret}".encode()).decode(),
"Content-Type": "application/x-www-form-urlencoded",
}
data = {"grant_type": "client_credentials"}
response = requests.post(token_url, data=data, headers=headers, timeout=15)
if response.status_code == 200:
result = response.json()
token = result.get("access_token")
expires_in = result.get("expires_in", 3600)
expiry_time = datetime.utcnow() + timedelta(seconds=expires_in - 60)
TokenLog.search([
('token_from', '=', 'Adempiere Altama'),
('is_active', '=', True),
]).write({'is_active': False})
TokenLog.create({
'token': token,
'expires_at': expiry_time,
'is_active': True,
'created_by': self.env.user.id if self.env.user else None,
'source': source,
'token_from': 'Adempiere Altama',
})
return token
else:
raise Exception(f"Gagal ambil token: {response.status_code} - {response.text}")
def action_create_order_altama(self):
ICP = self.env['ir.config_parameter'].sudo()
for order in self:
try:
token = self._get_fixco_token(source='manual')
url = ICP.get_param('endpoint.create.order.adempiere.altama')
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
payload = {
"date_po": order.date_approve.strftime("%Y%m%d%H%M%S"),
"no_po": order.name,
"details": [
{
"item_code": line.product_id.default_code or "",
"price": line.price_unit,
"qty": line.product_qty,
}
for line in order.order_line
],
}
response = requests.post(url, json=payload, headers=headers, timeout=20)
try:
result = response.json()
except json.JSONDecodeError:
raise Exception(f"Response bukan JSON valid: {response.text}")
if response.status_code == 200 and result.get("code") == "00":
contents = result.get("contents", {})
if isinstance(contents, dict):
order.order_altama_id = contents.get("req_id")
else:
order.order_altama_id = contents.get("req_id")
elif response.status_code == 404:
raise Exception("URL endpoint gak ditemukan (404). Pastikan path-nya benar di Altama API.")
elif response.status_code == 401:
token = self._get_fixco_token(source='auto')
headers["Authorization"] = f"Bearer {token}"
response = requests.post(url, json=payload, headers=headers, timeout=20)
elif response.status_code not in (200, 201):
raise Exception(f"Gagal kirim ke Altama: {response.status_code} - {response.text}")
self.message_post(body=f"✅ PO berhasil dikirim ke Altama!\nResponse: {json.dumps(result, indent=2)}")
except Exception as e:
self.message_post(body=f"❌ Gagal kirim ke Altama:
{str(e)}")
def action_get_order_altama(self):
ICP = self.env['ir.config_parameter'].sudo()
for order in self:
try:
# ============================
# Get Token
# ============================
token = self._get_fixco_token(source='manual')
url = ICP.get_param('endpoint.get.order.adempiere.altama')
if not url:
raise Exception("Parameter 'endpoint.adempiere.altama' belum diset di System Parameters.")
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
params = {
"orderreq_id": order.order_altama_id or 0,
}
# ============================
# Request ke API
# ============================
response = requests.get(url, headers=headers, params=params, timeout=20)
if response.status_code == 401:
token = self._get_fixco_token(source='auto')
headers["Authorization"] = f"Bearer {token}"
response = requests.get(url, headers=headers, params=params, timeout=20)
if response.status_code not in (200, 201):
raise Exception(f"Gagal ambil data dari Altama: {response.status_code} - {response.text}")
data = response.json()
# ============================
# Extract Data
# ============================
contents_root = data.get("contents", {})
contents_list = contents_root.get("contents", [])
if not isinstance(contents_list, list):
raise Exception("Format data contents dari Altama tidak sesuai (expected list).")
order.message_post(
body=f"✅ Berhasil ambil data dari Altama. Ditemukan {len(contents_list)} record."
)
# =====================================================
# LOOP MAIN DATA
# =====================================================
for item in contents_list:
req_id = item.get("req_id")
no_po = item.get("no_po")
list_item_po = item.get("list_Item_po", [])
list_soo = item.get("list_soo", [])
# ============================
# Isi Data SOO Ke Order
# ============================
soo_numbers = [s.get("no_soo") for s in list_soo if s.get("no_soo")]
unique_soo = list(set(soo_numbers))
if len(unique_soo) == 1:
order.soo_number = unique_soo[0]
if not order.picking_ids.number_soo:
order.picking_ids[0].number_soo = unique_soo[0]
elif len(unique_soo) > 1:
order.soo_number = ", ".join(unique_soo)
if not order.picking_ids.number_soo:
order.picking_ids[0].number_soo = ", ".join(unique_soo)
else:
order.soo_number = False
if list_soo:
first_soo = list_soo[0]
order.soo_price = first_soo.get("totalprice")
order.soo_discount = first_soo.get("diskon")
order.soo_tax = first_soo.get("ppn")
order.order_altama_id = req_id
# ============================
# Update Order Lines
# ============================
for item_line in list_item_po:
line = order.order_line.filtered(
lambda l: l.product_id.default_code == item_line.get("item_code")
)
if line:
line.write({
"description": item_line.get("description", ""),
"altama_ordered": item_line.get("qtyordered", 0),
"altama_delivered": item_line.get("qtydelivered", 0),
"altama_invoiced": item_line.get("qtyinvoiced", 0),
"docstatus_altama": item_line.get("docstatus", ""),
})
# =====================================================
# BUILD HTML TABLES FOR CHATTER
# =====================================================
# ---- SOO TABLE ----
soo_rows = ""
for s in list_soo:
soo_rows += f"""
| {s.get('no_soo')} |
{s.get('totalprice')} |
{s.get('diskon')} |
{s.get('ppn')} |
"""
soo_table = f"""
| SOO Number |
Total Price |
Diskon |
PPN |
{soo_rows or '| Tidak ada data SOO |
'}
"""
# ---- ITEM PO TABLE ----
po_rows = ""
for l in list_item_po:
desc = l.get("description") or ""
# Flag: row error kalau description tidak mulai dengan SOO/
is_error = desc and not desc.startswith("SOO/")
# Style row merah
row_style = "color:red; font-weight:bold;" if is_error else ""
po_rows += f"""
| {l.get('item_code')} |
{desc} |
{l.get('qtyordered')} |
{l.get('qtydelivered')} |
{l.get('qtyinvoiced')} |
{l.get('docstatus')} |
"""
po_table = f"""
| Item Code |
Description |
Ordered |
Delivered |
Invoiced |
Status |
{po_rows or '| Tidak ada item PO |
'}
"""
# ---- POST TO CHATTER ----
order.message_post(
body=f"""
📦 Data SOO
{soo_table}
📦 Data Item PO
{po_table}
"""
)
except Exception as e:
order.message_post(
body=f"❌ Gagal ambil data dari Altama:
{str(e)}"
)
def button_confirm(self):
if self.env.user.id not in [12, 10, 2, 15]:
self.check_buffer_stock()
if self.source == 'manual' and self.env.user.id not in [12, 10, 2, 15]:
raise UserError(_("Anda tidak memiliki akses untuk melakukan konfirmasi PO manual"))
res = super(PurchaseOrder, self).button_confirm()
if self.partner_id.id == 270:
self.action_create_order_altama()
return res
def check_buffer_stock(self):
insufficient_products = []
for line in self.order_line:
manage_stock = self.env['manage.stock'].search([
('product_id', '=', line.product_id.id)
], limit=1)
if manage_stock and line.product_qty > manage_stock.buffer_stock:
insufficient_products.append(
f"- {line.product_id.display_name} (qty: {line.product_qty}, buffer: {manage_stock.buffer_stock})"
)
if insufficient_products:
message = "Melebihi stock buffer untuk produk berikut:\n\n" + "\n".join(insufficient_products)
raise UserError(message)
@api.onchange('shipping_cost')
def _onchange_shipping_cost(self):
for order in self:
if not order.order_line:
continue
total_subtotal = sum(order.order_line.mapped('original_price_unit'))
if total_subtotal == 0:
continue
if order.shipping_cost and order.shipping_cost > 0:
# Distribusi ongkos kirim prorata
for line in order.order_line:
proportion = (line.original_price_subtotal / total_subtotal)
allocated_cost = order.shipping_cost * proportion
extra_cost_per_unit = allocated_cost / (line.product_qty or 1)
line.price_unit = line.original_price_unit + extra_cost_per_unit
else:
# Balikin harga ke semula kalau shipping_cost = 0
for line in order.order_line:
line.price_unit = line.original_price_unit
def _compute_count_journals(self):
for order in self:
journals = self.env['account.move'].search([
('purchase_order_id', '=', order.id),
('move_type', '!=', 'in_invoice')
])
order.count_journals = len(journals)
def action_view_related_journals(self):
self.ensure_one()
journals = self.env['account.move'].search([
('purchase_order_id', '=', self.id),
('move_type', '!=', 'in_invoice')
])
return {
'name': 'Journals',
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'tree,form',
'target': 'current',
'domain': [('id', 'in', journals.ids)],
}
@api.depends('order_line.price_total', 'biaya_lain_lain', 'discount_total', 'amount_tax', 'amount_discount')
def _amount_all(self):
super(PurchaseOrder, self)._amount_all()
for order in self:
line_discount = 0.0
for line in order.order_line:
if line.discount > 0:
line_discount += line.discount_amount
amount_total = order.amount_untaxed + order.amount_tax + order.biaya_lain_lain - order.discount_total
order.amount_discount = order.discount_total + line_discount
order.amount_total = order.currency_id.round(amount_total)
@api.depends('order_line.discount_amount')
def _compute_amount_discount(self):
for order in self:
order.amount_discount = sum(line.discount_amount for line in order.order_line)