from odoo import fields, models, api, _ from odoo.exceptions import AccessError, UserError, ValidationError from odoo.tools.float_utils import float_is_zero from collections import defaultdict from datetime import timedelta, datetime from datetime import timedelta, datetime as waktu from itertools import groupby import pytz, requests, json, requests from dateutil import parser import datetime import hmac import hashlib import base64 import requests import time import logging import re from hashlib import sha256 _logger = logging.getLogger(__name__) Request_URI = '/openapi/order/v1/print' ACCESS_KEY = '24bb6a1ec618ec6a' SECRET_KEY = '32e4a78ad05ee230' class StockPicking(models.Model): _inherit = 'stock.picking' check_product_lines = fields.One2many('check.product', 'picking_id', string='Check Product', auto_join=True, copy=False) order_reference = fields.Char('Order Reference') provider_name = fields.Char('Provider Name') tracking_number = fields.Char('Tracking Number') invoice_number = fields.Char('Invoice Number') pdf_label_url = fields.Char('PDF Label URL') invoice_mp = fields.Char(string='Invoice Marketplace') address = fields.Char('Address') note_by_buyer = fields.Char('Note By Buyer') carrier = fields.Char(string='Shipping Method') shipment_group_id = fields.Many2one('shipment.group', string='Shipment Group', copy=False) pdf_label_preview = fields.Binary( string="PDF Preview", compute="_compute_pdf_binary", store=False ) is_printed = fields.Boolean(string="Sudah Dicetak", default=False) is_return = fields.Boolean( string="Is Return", compute="_compute_is_return", store=True ) def button_validate(self): if len(self.check_product_lines) == 0 and not self.name.startswith('BU/INT'): raise UserError(_( "Belum ada check product, gabisa validate" )) return super(StockPicking, self).button_validate() @api.depends('move_lines.origin_returned_move_id') def _compute_is_return(self): for picking in self: # Picking dianggap return kalau ada minimal satu move yang direturn dari move lain picking.is_return = any(m.origin_returned_move_id for m in picking.move_lines) def action_cancel(self): for picking in self: if not self.env.user.id == 13: if picking.purchase_id.move_entry_id: raise UserError( 'Hanya Accounting yang bisa melakukan cancel karena di po nya sudah ada uang muka' ) super(StockPicking, self).action_cancel() def action_create_invoice_from_mr(self): """Create the invoice associated to the PO. """ if self.env.user.id == 13: raise UserError('Hanya Accounting yang bisa membuat Bill') precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') # custom here po = self.env['purchase.order'].search([ ('name', '=', self.group_id.name) ]) # 1) Prepare invoice vals and clean-up the section lines invoice_vals_list = [] for order in po: if order.invoice_status != 'to invoice': continue order = order.with_company(order.company_id) pending_section = None # Invoice values. invoice_vals = order._prepare_invoice() # Invoice line values (keep only necessary sections). for line in self.move_ids_without_package: po_line = self.env['purchase.order.line'].search( [('order_id', '=', po.id), ('product_id', '=', line.product_id.id)], limit=1) qty = line.product_uom_qty if po_line.display_type == 'line_section': pending_section = line continue if not float_is_zero(po_line.qty_to_invoice, precision_digits=precision): if pending_section: invoice_vals['invoice_line_ids'].append( (0, 0, pending_section._prepare_account_move_line_from_mr(po_line, qty))) pending_section = None invoice_vals['invoice_line_ids'].append( (0, 0, line._prepare_account_move_line_from_mr(po_line, qty))) invoice_vals_list.append(invoice_vals) if not invoice_vals_list: raise UserError( _('There is no invoiceable line. If a product has a control policy based on received quantity, please make sure that a quantity has been received.')) # 2) group by (company_id, partner_id, currency_id) for batch creation new_invoice_vals_list = [] for grouping_keys, invoices in groupby(invoice_vals_list, key=lambda x: ( x.get('company_id'), x.get('partner_id'), x.get('currency_id'))): origins = set() payment_refs = set() refs = set() ref_invoice_vals = None for invoice_vals in invoices: if not ref_invoice_vals: ref_invoice_vals = invoice_vals else: ref_invoice_vals['invoice_line_ids'] += invoice_vals['invoice_line_ids'] origins.add(invoice_vals['invoice_origin']) payment_refs.add(invoice_vals['payment_reference']) refs.add(invoice_vals['ref']) ref_invoice_vals.update({ 'ref': ', '.join(refs)[:2000], 'invoice_origin': ', '.join(origins), 'payment_reference': len(payment_refs) == 1 and payment_refs.pop() or False, }) new_invoice_vals_list.append(ref_invoice_vals) invoice_vals_list = new_invoice_vals_list # 3) Create invoices. moves = self.env['account.move'] AccountMove = self.env['account.move'].with_context(default_move_type='in_invoice') for vals in invoice_vals_list: moves |= AccountMove.with_company(vals['company_id']).create(vals) # 4) Some moves might actually be refunds: convert them if the total amount is negative # We do this after the moves have been created since we need taxes, etc. to know if the total # is actually negative or not moves.filtered( lambda m: m.currency_id.round(m.amount_total) < 0).action_switch_invoice_into_refund_credit_note() return self.action_view_invoice_from_mr(moves) def action_view_invoice_from_mr(self, invoices=False): """This function returns an action that display existing vendor bills of given purchase order ids. When only one found, show the vendor bill immediately. """ if not invoices: # Invoice_ids may be filtered depending on the user. To ensure we get all # invoices related to the purchase order, we read them in sudo to fill the # cache. self.sudo()._read(['invoice_ids']) invoices = self.invoice_ids result = self.env['ir.actions.act_window']._for_xml_id('account.action_move_in_invoice_type') # choose the view_mode accordingly if len(invoices) > 1: result['domain'] = [('id', 'in', invoices.ids)] elif len(invoices) == 1: res = self.env.ref('account.view_move_form', False) form_view = [(res and res.id or False, 'form')] if 'views' in result: result['views'] = form_view + [(state, view) for state, view in result['views'] if view != 'form'] else: result['views'] = form_view result['res_id'] = invoices.id else: result = {'type': 'ir.actions.act_window_close'} return result def _compute_pdf_binary(self): for record in self: record.pdf_label_preview = False if record.pdf_label_url: try: response = requests.get(record.pdf_label_url, timeout=10) if response.status_code == 200 and response.headers.get('Content-Type') == 'application/pdf': record.pdf_label_preview = base64.b64encode(response.content) except Exception as e: _logger.warning(f"Gagal mengambil PDF dari URL: {e}") @api.constrains('sale_id') def _check_sale_order(self): for picking in self: if picking.sale_id: picking.order_reference = picking.sale_id.name self.order_reference = picking.sale_id.order_reference self.invoice_mp = picking.sale_id.invoice_mp self.carrier = picking.sale_id.carrier self.address = picking.sale_id.address self.note_by_buyer = picking.sale_id.note_by_buyer def open_form_shipment_group(self): return { 'name': _('Create Shipment Group'), 'type': 'ir.actions.act_window', 'res_model': 'stock.picking.shipment_group', 'view_mode': 'form', 'target': 'new', 'context': { 'picking_ids': self.ids, } } def open_form_print_picking_list(self): return { 'name': _('Create Print Picking List'), 'type': 'ir.actions.act_window', 'res_model': 'stock.picking.print_picking_list', 'view_mode': 'form', 'target': 'new', 'context': { 'picking_ids': self.ids, } } def label_ginee(self): try: order_id = self.order_reference authorization = self.sign_request() headers = { 'Content-Type': 'application/json', 'X-Advai-Country': 'ID', 'Authorization': authorization } payload = { "orderId": order_id, "documentType": "LABEL" } url = "https://api.ginee.com/openapi/order/v1/print" response = requests.post( url, headers=headers, data=json.dumps(payload) ) if response.status_code == 200: data = response.json() if data.get('code') == 'SUCCESS' and data.get('message') == 'OK': logistic_info_list = data.get('data', {}).get('logisticsInfos') # Check if logistic_info exists and has at least one item if not logistic_info_list: raise UserError(_("No logistic information found in response")) logistic_info = logistic_info_list[0] self.pdf_label_url = data.get('data', {}).get('pdfUrl') or '' self.tracking_number = logistic_info.get('logisticsTrackingNumber') or '' self.provider_name = logistic_info.get('logisticsProviderName') or '' self.invoice_number = logistic_info.get('invoiceNumber') or '' else: raise UserError(_("API Error: %s - %s") % (data.get('code', 'UNKNOWN'), data.get('message', 'No error message'))) else: raise UserError(_("API request failed with status code: %s") % response.status_code) except Exception as e: raise UserError(_("Error: %s") % str(e)) def sign_request(self): signData = '$'.join(['POST', Request_URI]) + '$' authorization = ACCESS_KEY + ':' + base64.b64encode( hmac.new(SECRET_KEY.encode('utf-8'), signData.encode('utf-8'), digestmod=sha256).digest() ).decode('ascii') return authorization # def sync_qty_reserved_qty_done(self): # for picking in self: # for line in picking.move_line_ids_without_package: # line.qty_done = line.product_uom_qty # picking.button_validate() class CheckProduct(models.Model): _name = 'check.product' _description = 'Check Product' _order = 'picking_id, id' picking_id = fields.Many2one( 'stock.picking', string='Picking Reference', required=True, ondelete='cascade', index=True, copy=False, ) product_id = fields.Many2one('product.product', string='Product') quantity = fields.Float(string='Quantity') status = fields.Char(string='Status', compute='_compute_status') code_product = fields.Char(string='Code Product') @api.onchange('code_product') def _onchange_code_product(self): if not self.code_product: return # Cari product berdasarkan default_code, barcode, atau barcode_box product = self.env['product.product'].search([ '|', ('default_code', '=', self.code_product), '|', ('barcode', '=', self.code_product), ('barcode_box', '=', self.code_product) ], limit=1) if not product: raise UserError("Product tidak ditemukan") # Jika scan barcode_box, set quantity sesuai qty_pcs_box if product.barcode_box == self.code_product: self.product_id = product.id self.quantity = product.qty_pcs_box self.code_product = product.default_code or product.barcode # return { # 'warning': { # 'title': 'Info',8994175025871 # 'message': f'Product box terdeteksi. Quantity di-set ke {product.qty_pcs_box}' # } # } else: # Jika scan biasa self.product_id = product.id self.code_product = product.default_code or product.barcode self.quantity = 1 def unlink(self): # Get all affected pickings before deletion pickings = self.mapped('picking_id') # Store product_ids that will be deleted deleted_product_ids = self.mapped('product_id') # Perform the deletion result = super(CheckProduct, self).unlink() # After deletion, update moves for affected pickings for picking in pickings: # For products that were completely removed (no remaining check.product lines) remaining_product_ids = picking.check_product_lines.mapped('product_id') removed_product_ids = deleted_product_ids - remaining_product_ids # Set quantity_done to 0 for moves of completely removed products moves_to_reset = picking.move_ids_without_package.filtered( lambda move: move.product_id in removed_product_ids ) for move in moves_to_reset: move.quantity_done = 0.0 # Also sync remaining products in case their totals changed self._sync_check_product_to_moves(picking) return result @api.depends('quantity') def _compute_status(self): for record in self: moves = record.picking_id.move_ids_without_package.filtered( lambda move: move.product_id.id == record.product_id.id ) total_qty_in_moves = sum(moves.mapped('product_uom_qty')) if record.quantity < total_qty_in_moves: record.status = 'Pending' else: record.status = 'Done' def create(self, vals): # Create the record record = super(CheckProduct, self).create(vals) # Ensure uniqueness after creation if not self.env.context.get('skip_consolidate'): record.with_context(skip_consolidate=True)._consolidate_duplicate_lines() return record def write(self, vals): # Write changes to the record result = super(CheckProduct, self).write(vals) # Ensure uniqueness after writing if not self.env.context.get('skip_consolidate'): self.with_context(skip_consolidate=True)._consolidate_duplicate_lines() return result def _sync_check_product_to_moves(self, picking): """ Sinkronisasi quantity_done di move_ids_without_package dengan total quantity dari check.product berdasarkan product_id. """ for product_id in picking.check_product_lines.mapped('product_id'): # Totalkan quantity dari semua baris check.product untuk product_id ini total_quantity = sum( line.quantity for line in picking.check_product_lines.filtered(lambda line: line.product_id == product_id) ) # Update quantity_done di move yang relevan moves = picking.move_ids_without_package.filtered(lambda move: move.product_id == product_id) for move in moves: move.quantity_done = total_quantity def _consolidate_duplicate_lines(self): """ Consolidate duplicate lines with the same product_id under the same picking_id and sync the total quantity to related moves. """ for picking in self.mapped('picking_id'): lines_to_remove = self.env['check.product'] # Recordset untuk menyimpan baris yang akan dihapus product_lines = picking.check_product_lines.filtered(lambda line: line.product_id) # Group lines by product_id product_groups = {} for line in product_lines: product_groups.setdefault(line.product_id.id, []).append(line) for product_id, lines in product_groups.items(): if len(lines) > 1: # Consolidate duplicate lines first_line = lines[0] total_quantity = sum(line.quantity for line in lines) # Update the first line's quantity first_line.with_context(skip_consolidate=True).write({'quantity': total_quantity}) # Add the remaining lines to the lines_to_remove recordset lines_to_remove |= self.env['check.product'].browse([line.id for line in lines[1:]]) # Perform unlink after consolidation if lines_to_remove: lines_to_remove.unlink() # Sync total quantities to moves self._sync_check_product_to_moves(picking) @api.onchange('product_id', 'quantity') def check_product_validity(self): for record in self: if not record.picking_id or not record.product_id: continue # Filter moves related to the selected product moves = record.picking_id.move_ids_without_package.filtered( lambda move: move.product_id.id == record.product_id.id ) if not moves: raise UserError(( "The product '%s' tidak ada di operations. " ) % record.product_id.display_name) total_qty_in_moves = sum(moves.mapped('product_uom_qty')) # Find existing lines for the same product, excluding the current line existing_lines = record.picking_id.check_product_lines.filtered( lambda line: line.product_id == record.product_id ) if existing_lines: # Get the first existing line first_line = existing_lines[0] # Calculate the total quantity after addition total_quantity = sum(existing_lines.mapped('quantity')) if total_quantity > total_qty_in_moves: raise UserError(( "Quantity Product '%s' sudah melebihi quantity demand." ) % (record.product_id.display_name)) else: # Check if the quantity exceeds the allowed total if record.quantity == total_qty_in_moves: raise UserError(( "Quantity Product '%s' sudah melebihi quantity demand." ) % (record.product_id.display_name)) # Set the quantity to the entered value record.quantity = record.quantity class PickingReportCustom(models.AbstractModel): _name = 'report.fixco_custom.report_picking_list_custom' _description = 'Custom Picking List Report' def _get_report_values(self, docids, data=None): pickings = self.env['stock.picking'].browse(docids) already_printed = pickings.filtered(lambda p: p.is_printed) if already_printed: names = "\n- ".join(already_printed.mapped("name")) raise UserError( "Dokumen berikut sudah pernah di-print sebelumnya:\n\n- %s" % names ) pickings.write({'is_printed': True}) return { 'doc_ids': docids, 'doc_model': 'stock.picking', 'docs': pickings, }