diff options
65 files changed, 7179 insertions, 971 deletions
diff --git a/ab_openstreetmap/__init__.py b/ab_openstreetmap/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/ab_openstreetmap/__init__.py diff --git a/ab_openstreetmap/__manifest__.py b/ab_openstreetmap/__manifest__.py new file mode 100644 index 00000000..9d76c34c --- /dev/null +++ b/ab_openstreetmap/__manifest__.py @@ -0,0 +1,17 @@ +{ + 'name': "Openstreetmap Widget", + 'summary': """ + Openstreetmap Widget + """, + 'description': """ + Show Openstreetmap in Form View + Required for works add a lat long field to the model + """, + 'author': "PT. ISMATA NUSANTARA ABADI", + 'website': "http://www.ismata.co.id", + 'category': 'Uncategorized', + 'version': '0.1', + 'depends': ['base'], + "qweb": ['static/src/xml/googlemap_template.xml'], + 'data': ['views/templates.xml'], +} diff --git a/ab_openstreetmap/static/src/js/googlemap_widget.js b/ab_openstreetmap/static/src/js/googlemap_widget.js new file mode 100644 index 00000000..6471be0b --- /dev/null +++ b/ab_openstreetmap/static/src/js/googlemap_widget.js @@ -0,0 +1,133 @@ +odoo.define("ab_openstreetmap.googlemap_widget", function (require) { + "use strict"; + + const AbstractField = require("web.AbstractField"); + const fieldRegistry = require("web.field_registry"); + const rpc = require("web.rpc"); + + const GoogleMapWidget = AbstractField.extend({ + template: "googlemap_template", + + start: async function () { + await this._super(...arguments); + this._waitForMapReady(); + }, + + on_attach_callback: function () { + this._waitForMapReady(); // Trigger ulang saat widget dimunculkan + }, + + _waitForMapReady: function () { + const mapEl = document.getElementById("mapid"); + if (mapEl && mapEl.offsetWidth > 0 && mapEl.offsetHeight > 0) { + this._loadGoogle(); + } else { + setTimeout(() => this._waitForMapReady(), 300); // Tambah jeda untuk mode edit/tab + } + }, + + async _loadGoogle() { + const apiKey = "AIzaSyB7bG9aSNAJnSrj0Z7f1abFsqKVoiJfsPE"; // Ganti sesuai kebutuhan + const mapId = "1af072c8d80a2adec8057f34"; // Ganti sesuai kebutuhan + + if (!window.google || !window.google.maps) { + const script = document.createElement("script"); + script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&v=weekly&libraries=places,marker`; + script.async = true; + script.defer = true; + script.onload = () => this._initMap(mapId); + document.head.appendChild(script); + } else { + this._initMap(mapId); + } + }, + + async _initMap(mapId) { + const lat = parseFloat(this.recordData.latitude) || -6.2; + const lng = parseFloat(this.recordData.longtitude) || 106.816666; + const edit = this.mode === "edit"; + const mapEl = document.getElementById("mapid"); + if (!mapEl) return; + + mapEl.style.position = "relative"; + mapEl.style.minHeight = "400px"; // Pastikan map tidak collapse + + const { Map } = await google.maps.importLibrary("maps"); + const { AdvancedMarkerElement } = await google.maps.importLibrary("marker"); + + const map = new Map(mapEl, { + center: { lat, lng }, + zoom: 15, + mapId: mapId || undefined, + }); + + // Trigger resize untuk menangani kasus map awalnya hidden + setTimeout(() => { + google.maps.event.trigger(map, "resize"); + map.setCenter({ lat, lng }); // Reset ulang posisi setelah resize + }, 300); + + const marker = new AdvancedMarkerElement({ + map, + position: { lat, lng }, + gmpDraggable: edit, + title: "Lokasi", + }); + + if (edit) { + marker.addListener("dragend", () => { + const pos = marker.position; + this._updateCoordinates(pos.lat, pos.lng); + }); + + // Tambahkan input search + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = "Cari alamat..."; + input.id = "search-input"; + input.style.cssText = ` + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + z-index: 5; + width: 300px; + padding: 6px; + font-size: 14px; + border-radius: 4px; + border: 1px solid #ccc; + background: white; + `; + mapEl.appendChild(input); + + const autocomplete = new google.maps.places.Autocomplete(input); + autocomplete.addListener("place_changed", () => { + const place = autocomplete.getPlace(); + if (place && place.geometry && place.geometry.location) { + const pos = place.geometry.location; + map.setCenter(pos); + marker.position = pos; + this._updateCoordinates(pos.lat(), pos.lng()); + } + }); + } + }, + + _updateCoordinates(lat, lng) { + this.trigger_up("field_changed", { + dataPointID: this.dataPointID, + changes: { + latitude: lat.toString(), + longtitude: lng.toString(), + }, + viewType: this.viewType, + }); + }, + + isSet() { + return true; + }, + }); + + fieldRegistry.add("googlemap", GoogleMapWidget); +}); diff --git a/ab_openstreetmap/static/src/xml/googlemap_template.xml b/ab_openstreetmap/static/src/xml/googlemap_template.xml new file mode 100644 index 00000000..e4639a51 --- /dev/null +++ b/ab_openstreetmap/static/src/xml/googlemap_template.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<templates id="template" xml:space="preserve"> + <t t-name="googlemap_template"> + <div id="mapid" style="height: 400px; width: 100%;"></div> + </t> +</templates> + diff --git a/ab_openstreetmap/views/templates.xml b/ab_openstreetmap/views/templates.xml new file mode 100644 index 00000000..1f5a729b --- /dev/null +++ b/ab_openstreetmap/views/templates.xml @@ -0,0 +1,9 @@ +<odoo> + <data> + <template id="assets_backend" inherit_id="web.assets_backend"> + <xpath expr="." position="inside"> + <script type="text/javascript" src="/ab_openstreetmap/static/src/js/googlemap_widget.js"/> + </xpath> + </template> + </data> +</odoo> diff --git a/indoteknik_api/controllers/api_v1/cart.py b/indoteknik_api/controllers/api_v1/cart.py index 7a40b1e2..fdc237cf 100644 --- a/indoteknik_api/controllers/api_v1/cart.py +++ b/indoteknik_api/controllers/api_v1/cart.py @@ -16,24 +16,31 @@ class Cart(controller.Controller): offset = int(kw.get('offset', 0)) query = [('user_id', '=', user_id)] carts = user_cart.search(query, limit=limit, offset=offset, order='create_date desc') - # carts.write({'source': 'add_to_cart'}) + products = [] products_inactive = [] + for cart in carts: - if cart.product_id: - price = cart.product_id._v2_get_website_price_include_tax() - if cart.product_id.active and price > 0: - product = cart.with_context(price_for="web").get_products() - for product_active in product: - products.append(product_active) + try: + if cart.product_id: + price = cart.product_id._v2_get_website_price_include_tax() + + if cart.product_id.active and price > 0: + product = cart.with_context(price_for="web").get_products() + for product_active in product: + products.append(product_active) + else: + product_inactives = cart.with_context(price_for="web").get_products() + for inactives in product_inactives: + products_inactive.append(inactives) else: - product_inactives = cart.with_context(price_for="web").get_products() - for inactives in product_inactives: - products_inactive.append(inactives) - else: - program = cart.with_context(price_for="web").get_products() - for programs in program: - products.append(programs) + program = cart.with_context(price_for="web").get_products() + for programs in program: + products.append(programs) + + except Exception as e: + continue + data = { 'product_total': user_cart.search_count(query), 'products': products, diff --git a/indoteknik_api/controllers/api_v1/partner.py b/indoteknik_api/controllers/api_v1/partner.py index 126fded4..b8bd21be 100644 --- a/indoteknik_api/controllers/api_v1/partner.py +++ b/indoteknik_api/controllers/api_v1/partner.py @@ -1,6 +1,6 @@ from .. import controller from odoo import http -from odoo.http import request +from odoo.http import request, Response from odoo import fields import json import base64 @@ -61,46 +61,48 @@ class Partner(controller.Controller): partner = request.env['res.users'].api_address_response(partner) return self.response(partner) - @http.route(prefix + 'partner/<id>/address', auth='public', methods=['PUT', 'OPTIONS'], csrf=False) + @http.route(prefix + 'partner/<id>/address', type="json", auth='public', methods=['PUT', 'OPTIONS'], csrf=False, cors='*') @controller.Controller.must_authorized() - def write_partner_address_by_id(self, **kw): + def write_partner_address_by_id(self, id, **kw): + headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': '*' + } + if request.httprequest.method == 'OPTIONS': + return Response(status=200, headers=headers) try: - params = self.get_request_params(kw, { - 'id': ['required', 'number'], - 'type': ['default:other'], - 'name': ['required'], - 'email': ['required'], - 'mobile': ['required'], - 'phone': [''], - 'street': ['required'], - 'state_id': ['required', 'number', 'alias:state_id'], - 'city_id': ['required', 'number', 'alias:kota_id'], - 'district_id': ['number', 'alias:kecamatan_id'], - 'sub_district_id': ['number', 'alias:kelurahan_id', 'exclude_if_null'], - 'zip': ['required'], - 'longtitude': '', - 'latitude': '', - 'address_map': [], - 'alamat_lengkap_text': [] - }) + params = self.get_request_params(request.jsonrequest, { + 'id': ['required', 'number'], + 'type': ['default:other'], + 'name': ['required'], + 'email': ['required'], + 'mobile': ['required'], + 'phone': [''], + 'street': ['required'], + 'state_id': ['required', 'alias:state_id'], + 'city_id': ['required', 'alias:kota_id'], + 'district_id': ['alias:kecamatan_id'], + 'sub_district_id': ['alias:kelurahan_id', 'exclude_if_null'], + 'zip': ['required'], + 'longtitude': '', + 'latitude': '', + 'address_map': [], + 'alamat_lengkap_text': [] + }) if not params['valid']: - return self.response(code=400, description=params) - - partner = request.env[self._name].sudo().search([('id', '=', params['value']['id'])], limit=1) + return {'headers' : headers,'code': 400, 'description': params} + partner = request.env['res.partner'].sudo().search([('id', '=', id)], limit=1) if not partner: - return self.response(code=404, description='User not found') - - try: - partner.write(params['value']) - except Exception as e: - return self.response(code=500, description=f'Error writing partner data: {str(e)}') + return {'headers' : headers,'code': 404, 'description': 'User not found'} - return self.response({'id': partner.id}) + partner.write(params['value']) + return {'id': partner.id, 'headers' : headers} except Exception as e: - return self.response(code=500, description=f'Unexpected error: {str(e)}') + return {'headers' : headers,'code': 500, 'description': f'Internal Error: {str(e)}'} @http.route(prefix + 'partner/address', auth='public', methods=['POST', 'OPTIONS'], csrf=False) @controller.Controller.must_authorized() @@ -132,69 +134,83 @@ class Partner(controller.Controller): 'id': partner.id, }) - @http.route(prefix + 'partner/<id>', auth='public', methods=['PUT', 'OPTIONS'], csrf=False) + @http.route(prefix + 'partner/<int:id>', auth='public', methods=['POST', 'OPTIONS'], csrf=False) @controller.Controller.must_authorized() - def write_partner_by_id(self, **kw): - params = self.get_request_params(kw, { - 'id': ['', 'number'], - 'name': [], - 'company_type_id': ['number'], - 'industry_id': ['number'], - 'tax_name': ['alias:nama_wajib_pajak'], - 'npwp': [], - 'alamat_lengkap_text': [], - 'street': [], - 'email': [], - 'mobile': [] - }) - id_user = self.get_request_params(kw, { - 'id_user': ['number'] - }) - params_user = self.get_request_params(kw, { - 'company_type_id': ['number'], - 'industry_id': ['number'], - 'tax_name': ['alias:nama_wajib_pajak'], - 'npwp': [], - 'alamat_lengkap_text': [], - }) + def write_partner_by_id(self, id, **kw): + try: + # Ambil data JSON langsung + request_data = kw + + partner = request.env['res.partner'].sudo().browse(id) + if not partner.exists(): + return self.response({ + 'code': 400, + 'description': 'Partner not found' + }) - if not params['valid']: - return self.response(code=400, description=params) + partner_params = self.get_request_params(request_data, { + 'tax_name': ['alias:nama_wajib_pajak'], + 'company_type_id': ['number'], + 'industry_id': ['number'], + 'npwp': [], + 'alamat_lengkap_text': [], + 'street': [], + 'email': [], + 'mobile': [] + }) - partner = request.env[self._name].search([('id', '=', params['value']['id'])], limit=1) - user = request.env[self._name].search([('id', '=', id_user['value']['id_user'])], limit=1) - if not partner: - return self.response(code=404, description='Partner not found') + if not partner_params['valid']: + return self.response({ + 'code': 400, + 'description': partner_params + }) - if not params['value'].get('tax_name'): - params['value']['nama_wajib_pajak'] = params['value'].get('name') - params_user['value']['nama_wajib_pajak'] = params_user['value'].get('name') + partner_values = partner_params['value'] - if not params['value'].get('alamat_lengkap_text'): - params['value']['alamat_lengkap_text'] = params['value'].get('street') - params_user['value']['alamat_lengkap_text'] = params_user['value'].get('street') + if 'id_user' in request_data: + user_params = self.get_request_params(request_data, { + 'id_user': ['required', 'number'], + 'company_type_id': ['number'], + 'industry_id': ['number'], + 'tax_name': ['alias:nama_wajib_pajak'], + 'npwp': [], + 'alamat_lengkap_text': [], + }) - if not params['value'].get('npwp'): - params['value']['npwp'] = "00.000.000.0-000.000" - params_user['value']['npwp'] = "00.000.000.0-000.000" + if not user_params['valid']: + return self.response({ + 'code': 400, + 'description': user_params + }) - # Filter parameter yang memiliki nilai saja untuk partner - params_filtered = {k: v for k, v in params['value'].items() if v} + user = request.env['res.partner'].sudo().browse(int(user_params['value']['id_user'])) + if user.exists(): + user_values = user_params['value'] - # Filter parameter yang memiliki nilai saja untuk user - params_user_filtered = {k: v for k, v in params_user['value'].items() if v} + if not user_values.get('tax_name'): + user_values['nama_wajib_pajak'] = user_values.get('name', user.name) - # Update partner dan user hanya dengan parameter yang memiliki nilai - if params_filtered: - partner.write(params_filtered) + if not user_values.get('alamat_lengkap_text'): + user_values['alamat_lengkap_text'] = user_values.get('street', user.street) - if params_user_filtered: - user.write(params_user_filtered) + if not user_values.get('npwp'): + user_values['npwp'] = "00.000.000.0-000.000" - # Return response dengan ID partner yang di-update - return self.response({ - 'id': partner.id - }) + user_values_filtered = {k: v for k, v in user_values.items() if k != 'id_user' and v is not None} + if user_values_filtered: + user.write(user_values_filtered) + + partner.write(partner_values) + + return self.response({ + 'partner_id': partner.id + }) + + except Exception as e: + return self.response({ + 'code': 500, + 'description': f'Internal Error: {str(e)}' + }) @http.route(prefix + 'partner/industry', auth='public', methods=['GET', 'OPTIONS']) @controller.Controller.must_authorized() @@ -306,7 +322,7 @@ class Partner(controller.Controller): data = True if pengajuan_tempo.id else False return self.response(data) - @http.route(prefix + 'partner/pengajuan_tempo', auth='public', methods=['POST'], csrf=False) + @http.route(prefix + 'partner/pengajuan_tempo', auth='public', methods=['POST', 'OPTIONS'], csrf=False) @controller.Controller.must_authorized() def write_pengajuan_tempo(self, **kw): try: diff --git a/indoteknik_api/controllers/api_v1/product.py b/indoteknik_api/controllers/api_v1/product.py index a88c3368..e97a7ff8 100644 --- a/indoteknik_api/controllers/api_v1/product.py +++ b/indoteknik_api/controllers/api_v1/product.py @@ -2,6 +2,7 @@ from .. import controller from odoo import http from odoo.http import request, Response from datetime import datetime, timedelta +import pytz import ast import logging import math @@ -46,12 +47,15 @@ class Product(controller.Controller): ('product_id', 'in', product_ids), ('is_winner', '=', True) ]) + jakarta = pytz.timezone("Asia/Jakarta") + start_date = datetime.now(jakarta) + + offset, is3pm = request.env['sale.order'].get_days_until_next_business_day(start_date) + additional_days = offset - start_date = datetime.today().date() - additional_days = request.env['sale.order'].get_days_until_next_business_day(start_date) include_instant = True - if(len(products) != len(product_ids)): + if(len(products) == len(product_ids)): products_data_params = {product["id"] : product for product in product_data } all_fast_products = all( @@ -63,8 +67,8 @@ class Product(controller.Controller): return self.response({ 'include_instant': include_instant, 'sla_duration': 1, - 'sla_additional_days': additional_days, - 'sla_total' : int(1) + int(additional_days), + 'sla_additional_days': int(additional_days), + 'sla_total' : int(additional_days), 'sla_unit': 'Hari' }) @@ -96,27 +100,40 @@ class Product(controller.Controller): }) @http.route(prefix + 'product_variant/<id>/stock', auth='public', methods=['GET', 'OPTIONS']) - @controller.Controller.must_authorized() + @controller.Controller.must_authorized() def get_product_template_stock_by_id(self, **kw): id = int(kw.get('id')) date_7_days_ago = datetime.now() - timedelta(days=7) - product = request.env['product.product'].search( - [('id', '=', id)], limit=1) - product_sla = request.env['product.sla'].search( - [('product_variant_id', '=', id)], limit=1) + product_pruchase = request.env['purchase.pricelist'].search([ + ('product_id', '=', id), + ('is_winner', '=', True) + ]) stock_vendor = request.env['stock.vendor'].search([ ('product_variant_id', '=', id), ('write_date', '>=', date_7_days_ago.strftime("%Y-%m-%d %H:%M:%S")) ], limit=1) + + product = product_pruchase.product_id + + vendor_sla = request.env['vendor.sla'].search([('id_vendor', '=', product_pruchase.vendor_id.id)], limit=1) + slatime = 15 + if vendor_sla: + if vendor_sla.unit == 'hari': + vendor_duration = vendor_sla.duration * 24 * 60 + else : + vendor_duration = vendor_sla.duration * 60 + + estimation_sla = (1 * 24 * 60) + vendor_duration + estimation_sla_days = estimation_sla / (24 * 60) + slatime = math.ceil(estimation_sla_days) qty_available = product.qty_free_bandengan - if qty_available < 1 : qty_available = 0 qty = 0 - sla_date = '-' + sla_date = f'{slatime} Hari' # Qty Stock Vendor qty_vendor = stock_vendor.quantity @@ -136,28 +153,89 @@ class Product(controller.Controller): if qty_available > 0: qty = qty_available + total_adem + total_excell - sla_date = product_sla.sla or 1 + sla_date = '1 Hari' elif qty_altama > 0 or qty_vendor > 0: qty = total_adem if qty_altama > 0 else total_excell - sla_date = product_sla.sla + sla_date = f'{slatime} Hari' else: - sla_date = product_sla.sla + sla_date = f'{slatime} Hari' except: print('error') else: if qty_available > 0: qty = qty_available - sla_date = product_sla.sla or 'Indent' + sla_date = f'1 Hari' elif qty_vendor > 0: qty = total_excell - sla_date = '2-4 Hari' + sla_date = f'{slatime} Hari' data = { 'qty': qty, 'sla_date': sla_date } - return self.response(data, headers=[('Cache-Control', 'max-age=600, private')]) + return self.response(data, headers=[('Cache-Control', 'max-age=600, private')]) + # def get_product_template_stock_by_id(self, **kw): + # id = int(kw.get('id')) + # date_7_days_ago = datetime.now() - timedelta(days=7) + # product = request.env['product.product'].search( + # [('id', '=', id)], limit=1) + # product_sla = request.env['product.sla'].search( + # [('product_variant_id', '=', id)], limit=1) + # stock_vendor = request.env['stock.vendor'].search([ + # ('product_variant_id', '=', id), + # ('write_date', '>=', date_7_days_ago.strftime("%Y-%m-%d %H:%M:%S")) + # ], limit=1) + + # qty_available = product.qty_free_bandengan + + + # if qty_available < 1 : + # qty_available = 0 + + # qty = 0 + # sla_date = '-' + + # # Qty Stock Vendor + # qty_vendor = stock_vendor.quantity + # qty_vendor -= int(qty_vendor * 0.1) + # qty_vendor = math.ceil(float(qty_vendor)) + # total_excell = qty_vendor + + # is_altama_product = product.x_manufacture.id in [10, 122, 89] + # if is_altama_product: + # try: + # # Qty Altama + # qty_altama = request.env['product.template'].get_stock_altama( + # product.default_code) + # qty_altama -= int(qty_altama * 0.1) + # qty_altama = math.ceil(float(qty_altama)) + # total_adem = qty_altama + + # if qty_available > 0: + # qty = qty_available + total_adem + total_excell + # sla_date = product_sla.sla or 1 + # elif qty_altama > 0 or qty_vendor > 0: + # qty = total_adem if qty_altama > 0 else total_excell + # sla_date = product_sla.sla + # else: + # sla_date = product_sla.sla + # except: + # print('error') + # else: + # if qty_available > 0: + # qty = qty_available + # sla_date = product_sla.sla or 'Indent' + # elif qty_vendor > 0: + # qty = total_excell + # sla_date = '2-4 Hari' + + # data = { + # 'qty': qty, + # 'sla_date': sla_date + # } + + # return self.response(data, headers=[('Cache-Control', 'max-age=600, private')]) @http.route(prefix + 'product_variant/<id>/qty_available', auth='public', methods=['GET', 'OPTIONS']) @controller.Controller.must_authorized() diff --git a/indoteknik_api/controllers/api_v1/sale_order.py b/indoteknik_api/controllers/api_v1/sale_order.py index e87b357e..3ecaff57 100644 --- a/indoteknik_api/controllers/api_v1/sale_order.py +++ b/indoteknik_api/controllers/api_v1/sale_order.py @@ -521,7 +521,7 @@ class SaleOrder(controller.Controller): if params['value']['type'] == 'sale_order': parameters['approval_status'] = 'pengajuan1' - sale_order = request.env['sale.order'].create([parameters]) + sale_order = request.env['sale.order'].with_context(from_website_checkout=True).create([parameters]) sale_order.onchange_partner_contact() user_id = params['value']['user_id'] @@ -540,6 +540,7 @@ class SaleOrder(controller.Controller): 'product_available_quantity': cart['available_quantity'] }) order_line.product_id_change() + order_line.weight = order_line.product_id.weight order_line.onchange_vendor_id() order_line.price_unit = cart['price']['price'] order_line.discount = cart['price']['discount_percentage'] @@ -575,6 +576,7 @@ class SaleOrder(controller.Controller): elif sale_order._requires_approval_margin_manager(): sale_order.approval_status = 'pengajuan1' # user_cart.browse(cart_ids).unlink() + sale_order._auto_set_shipping_from_website() return self.response({ 'id': sale_order.id, 'name': sale_order.name diff --git a/indoteknik_api/controllers/api_v1/state.py b/indoteknik_api/controllers/api_v1/state.py index 958359a7..c03042e7 100644 --- a/indoteknik_api/controllers/api_v1/state.py +++ b/indoteknik_api/controllers/api_v1/state.py @@ -8,12 +8,8 @@ class District(controller.Controller): @http.route(prefix + 'state', auth='public', methods=['GET', 'OPTIONS']) @controller.Controller.must_authorized() def get_state(self, **kw): - tempo = kw.get('tempo') - parameters = [] - if tempo == 'true': - parameters.append(('country_id', '=', 100)) + parameters = [('country_id', '=', 100)] # selalu ambil country_id = 100 - name = kw.get('name') if name: name = '%' + name.replace(' ', '%') + '%' @@ -22,7 +18,7 @@ class District(controller.Controller): states = request.env['res.country.state'].search(parameters) data = [] for state in states: - data.append({ 'id': state.id, 'name': state.name}) - + data.append({'id': state.id, 'name': state.name}) + return self.response(data) diff --git a/indoteknik_api/controllers/api_v1/stock_picking.py b/indoteknik_api/controllers/api_v1/stock_picking.py index 31706b99..85b0fbba 100644 --- a/indoteknik_api/controllers/api_v1/stock_picking.py +++ b/indoteknik_api/controllers/api_v1/stock_picking.py @@ -1,9 +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/' @@ -22,42 +27,44 @@ class StockPicking(controller.Controller): if not get_params['valid']: return self.response(code=400, description=get_params) - + params = get_params['value'] partner_id = params['partner_id'] limit = params['limit'] offset = params['offset'] - + child_ids = request.env['res.partner'].browse(partner_id).get_child_ids() pending_domain = [('driver_departure_date', '=', False), ('driver_arrival_date', '=', False)] shipment_domain = [('driver_departure_date', '!=', False), ('driver_arrival_date', '=', False)] shipment_domain2 = [('driver_departure_date', '!=', False), ('sj_return_date', '=', False)] - completed_domain = [('driver_departure_date', '!=', False),'|', ('driver_arrival_date', '!=', False), ('sj_return_date', '!=', False)] + completed_domain = [('driver_departure_date', '!=', False), '|', ('driver_arrival_date', '!=', False), + ('sj_return_date', '!=', False)] completed_domain2 = [('driver_departure_date', '!=', False), ('sj_return_date', '!=', False)] picking_model = request.env['stock.picking'] domain = [ - ('partner_id', 'in', child_ids), - ('sale_id', '!=', False), - ('origin', 'ilike', 'SO%'), - ('state', '!=', 'cancel') + ('partner_id', 'in', child_ids), + ('sale_id', '!=', False), + ('origin', 'ilike', 'SO%'), + ('state', '!=', 'cancel'), + ('name', 'ilike', 'BU/OUT%') ] - + if params['q']: query_like = '%' + params['q'].replace(' ', '%') + '%' - domain += ['|', '|', - ('name', 'ilike', query_like), - ('sale_id.client_order_ref', 'ilike', query_like), - ('delivery_tracking_no', 'ilike', query_like) - ] + domain += ['|', '|', + ('name', 'ilike', query_like), + ('sale_id.client_order_ref', 'ilike', query_like), + ('delivery_tracking_no', 'ilike', query_like) + ] default_domain = domain.copy() - + if params['status'] == 'pending': domain += pending_domain elif params['status'] == 'shipment': - domain += shipment_domain + shipment_domain2 + domain += shipment_domain + shipment_domain2 elif params['status'] == 'completed': domain += completed_domain @@ -65,7 +72,7 @@ class StockPicking(controller.Controller): res_pickings = [] for picking in stock_pickings: manifests = picking.get_manifests() - + res_pickings.append({ 'id': picking.id, 'name': picking.name, @@ -86,12 +93,12 @@ class StockPicking(controller.Controller): 'summary': { 'pending_count': picking_model.search_count(default_domain + pending_domain), 'shipment_count': picking_model.search_count(default_domain + shipment_domain + shipment_domain2), - 'completed_count': picking_model.search_count(default_domain + completed_domain ) + 'completed_count': picking_model.search_count(default_domain + completed_domain) }, 'picking_total': picking_model.search_count(domain), 'pickings': res_pickings }) - + @http.route(PREFIX_PARTNER + 'stock-picking/<id>/tracking', auth='public', method=['GET', 'OPTIONS']) @controller.Controller.must_authorized(private=True, private_key='partner_id') def get_partner_stock_picking_detail_tracking(self, **kw): @@ -101,7 +108,6 @@ class StockPicking(controller.Controller): picking = picking_model.browse(id) if not picking: return self.response(None) - hostori = picking.get_tracking_detail() return self.response(picking.get_tracking_detail()) @http.route(prefix + 'stock-picking/<id>/tracking', auth='public', method=['GET', 'OPTIONS']) @@ -140,49 +146,105 @@ class StockPicking(controller.Controller): return self.response({ 'name': picking_data.name }) - - @http.route(prefix + 'webhook/biteship', type='json', auth='public', methods=['POST'], csrf=False) - def udpate_status_from_bitehsip(self, **kw): + + @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: - if not request.jsonrequest: - return "ok" + # Karena type='json', Odoo secara otomatis akan mem-parsing JSON untuk Anda. + # 'data' akan berisi dictionary Python dari payload JSON Biteship. + data = request.jsonrequest - data = request.jsonrequest # Ambil data JSON dari request - event = data.get('event') + # 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)}") - # 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) + 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'} - return {'success': True, 'message': f'Webhook {event} received'} except Exception as e: - return {'success': False, 'message': str(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)} 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()}) - + picking = request.env['stock.picking'].sudo().search([ + ('biteship_id', '=', data.get('order_id')) + ], limit=1) + + if not picking: + _logger.warning(f"[Webhook] Tidak ditemukan picking untuk order_id {data.get('order_id')}") + return + + 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) + + + 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') - }) -
\ No newline at end of file + 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_api/models/sale_order.py b/indoteknik_api/models/sale_order.py index 4fc96386..5427ed07 100644 --- a/indoteknik_api/models/sale_order.py +++ b/indoteknik_api/models/sale_order.py @@ -30,20 +30,24 @@ class SaleOrder(models.Model): 'payment_type': sale_order.payment_type, 'pickings': [] } - # Urutkan picking berdasarkan create_date descending - sorted_pickings = sale_order.picking_ids.sorted(key=lambda p: p.create_date, reverse=True) - - for picking in sorted_pickings: + for picking in sale_order.picking_ids: + picking_model = self.env['stock.picking'].sudo().search([('id', '=', picking.id), ('name', 'like', '%BU/OUT/%')], limit=1) + if not picking_model: + continue + response = picking_model.get_tracking_detail() + data['pickings'].append({ + 'waybill_number' : response['waybill_number'] or '', + 'delivered_date': response['delivered_date'], + 'delivery_order' : { + 'carrier' : response['delivery_order']['carrier'] or '', + 'service' : response['delivery_order']['service'] or '' + }, + 'eta' : response['eta'], 'id': picking.id, 'name': picking.name, - 'tracking_number': picking.delivery_tracking_no or '', - 'carrier_name': picking.carrier_id.name or '', - 'delivered': picking.waybill_id.delivered or picking.driver_arrival_date != False or picking.sj_return_date != False, - 'date': self.env['rest.api'].datetime_to_str(picking.driver_departure_date, '%d/%m/%Y'), - 'eta': picking.generate_eta_delivery(), - 'service_type': sale_order.delivery_service_type or '', - 'weight_total': picking.weight or 0, + # 'tracking_number': picking.delivery_tracking_no or '', + # 'delivered': picking.waybill_id.delivered or picking.driver_arrival_date != False or picking.sj_return_date != False, }) if sale_order.state == 'cancel': data['status'] = 'cancel' diff --git a/indoteknik_custom/__manifest__.py b/indoteknik_custom/__manifest__.py index 9fe3dcdb..2a4db273 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', @@ -167,6 +169,11 @@ 'views/coretax_faktur.xml', 'views/public_holiday.xml', 'views/stock_inventory.xml', + 'views/sale_order_delay.xml', + 'views/tukar_guling.xml', + # 'views/tukar_guling_return_views.xml' + 'views/tukar_guling_po.xml', + # 'views/refund_sale_order.xml', ], 'demo': [], 'css': [], diff --git a/indoteknik_custom/models/__init__.py b/indoteknik_custom/models/__init__.py index 08fa9803..51d25c1f 100755 --- a/indoteknik_custom/models/__init__.py +++ b/indoteknik_custom/models/__init__.py @@ -149,4 +149,10 @@ from . import sales_order_koli from . import stock_backorder_confirmation 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 refund_sale_order +# 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 30de67be..1a6fad1c 100644 --- a/indoteknik_custom/models/account_move.py +++ b/indoteknik_custom/models/account_move.py @@ -1,5 +1,6 @@ from odoo import models, api, fields from odoo.exceptions import AccessError, UserError, ValidationError +from markupsafe import escape as html_escape from datetime import timedelta, date, datetime from pytz import timezone, utc import logging @@ -8,12 +9,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") @@ -67,13 +71,189 @@ class AccountMove(models.Model): is_hr = fields.Boolean(string="Is HR?", default=False) purchase_order_id = fields.Many2one('purchase.order', string='Purchase Order') length_of_payment = fields.Integer(string="Length of Payment", compute='compute_length_of_payment') + reklas_misc_id = fields.Many2one('account.move', string='Journal Entries Reklas') + # Di model account.move + 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?') + refund_id = fields.Many2one('refund.sale.order', string='Refund Reference') + refund_so_ids = fields.Many2many( + 'sale.order', + 'account_move_sale_order_rel', + 'move_id', + 'sale_order_id', + string='Group SO Number' + ) + + refund_so_links = fields.Html( + string="Group SO Numbers", + compute="_compute_refund_so_links", + ) + + has_refund_so = fields.Boolean( + string='Has Refund SO', + compute='_compute_has_refund_so', + ) + + # 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 + + # 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") + + + @api.onchange('invoice_date') + def _onchange_invoice_date(self): + if self.invoice_date: + self.date = self.invoice_date + + @api.onchange('date') + def _onchange_date(self): + if self.date: + self.invoice_date = self.date + + @api.depends('refund_so_ids') + def _compute_refund_so_links(self): + for rec in self: + links = [] + for so in rec.refund_so_ids: + url = f"/web#id={so.id}&model=sale.order&view_type=form" + name = html_escape(so.name or so.display_name) + links.append(f'<a href="{url}" target="_blank">{name}</a>') + rec.refund_so_links = ', '.join(links) if links else "-" + + @api.depends('refund_so_ids') + def _compute_has_refund_so(self): + for rec in self: + rec.has_refund_so = bool(rec.refund_so_ids) + + + # 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 @@ -126,13 +306,38 @@ class AccountMove(models.Model): } template.send_mail(record.id, email_values=email_values, force_send=True) + # @api.model + # def create(self, vals): + # vals['nomor_kwitansi'] = self.env['ir.sequence'].next_by_code('nomor.kwitansi') or '0' + # result = super(AccountMove, self).create(vals) + # # result._update_line_name_from_ref() + # return result + @api.model def create(self, vals): - vals['nomor_kwitansi'] = self.env['ir.sequence'].next_by_code('nomor.kwitansi') or '0' + vals['nomor_kwitansi'] = self.env['ir.sequence'].next_by_code('nomor.kwitansi') or '0' result = super(AccountMove, self).create(vals) - # result._update_line_name_from_ref() + + # Tambahan: jika ini Vendor Bill dan tanggal belum diisi + if result.move_type == 'in_invoice' and not vals.get('invoice_date') and not vals.get('date'): + po = result.purchase_order_id + if po: + # Cari receipt dari PO + picking = self.env['stock.picking'].search([ + ('purchase_id', '=', po.id), + ('picking_type_code', '=', 'incoming'), + ('state', '=', 'done'), + ('date_done', '!=', False), + ], order='date_done desc', limit=1) + + if picking: + receipt_date = picking.date_done + result.invoice_date = receipt_date + result.date = receipt_date + return result + def compute_so_shipping_paid_by(self): for record in self: record.so_shipping_paid_by = record.sale_id.shipping_paid_by @@ -381,18 +586,18 @@ class AccountMove(models.Model): return invoices def export_faktur_to_xml(self): - valid_invoices = self - # Panggil model coretax.faktur untuk menghasilkan XML coretax_faktur = self.env['coretax.faktur'].create({}) - response = coretax_faktur.export_to_download(invoices=valid_invoices) - current_time = datetime.utcnow() - # Tandai faktur sebagai sudah diekspor + response = coretax_faktur.export_to_download( + invoices=valid_invoices, + down_payments=[inv.down_payment for inv in valid_invoices], + ) + valid_invoices.write({ 'is_efaktur_exported': True, - 'date_efaktur_exported': current_time, # Set tanggal ekspor + 'date_efaktur_exported': datetime.utcnow(), }) - return response + return response
\ No newline at end of file diff --git a/indoteknik_custom/models/approval_payment_term.py b/indoteknik_custom/models/approval_payment_term.py new file mode 100644 index 00000000..6c857b45 --- /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 and not self.approve_sales_manager): + 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 and not self.approve_finance): + self.approve_finance = True + self.state = 'waiting_approval_leader' + return + + if (user.id == 7 and self.approve_finance) or (is_it and not self.approve_leader): + 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/automatic_purchase.py b/indoteknik_custom/models/automatic_purchase.py index c9edf07c..83a7cb3c 100644 --- a/indoteknik_custom/models/automatic_purchase.py +++ b/indoteknik_custom/models/automatic_purchase.py @@ -486,7 +486,7 @@ class AutomaticPurchase(models.Model): # _logger.info('test %s' % point.product_id.name) if point.product_id.qty_available_bandengan > point.product_min_qty: continue - qty_purchase = point.product_max_qty - point.product_id.qty_incoming_bandengan - point.product_id.qty_onhand_bandengan + qty_purchase = point.product_max_qty - point.product_id.qty_incoming_bandengan - point.product_id.qty_available_bandengan po_line = self.env['purchase.order.line'].search([('product_id', '=', point.product_id.id), ('order_id.state', '=', 'done')], order='id desc', limit=1) if self.vendor_id: diff --git a/indoteknik_custom/models/commision.py b/indoteknik_custom/models/commision.py index eeaa8efc..26b5df37 100644 --- a/indoteknik_custom/models/commision.py +++ b/indoteknik_custom/models/commision.py @@ -148,6 +148,7 @@ class CustomerCommision(models.Model): _order = 'id desc' _inherit = ['mail.thread'] _rec_name = 'number' + _description = 'Customer Benefits' number = fields.Char(string='Document No', index=True, copy=False, readonly=True) date_from = fields.Date(string='Date From', required=True) @@ -175,10 +176,24 @@ class CustomerCommision(models.Model): ('approved', 'Approved'), ('reject', 'Rejected'), ], string='Status') - commision_percent = fields.Float(string='Commision %', tracking=3) - commision_amt = fields.Float(string='Commision Amount', tracking=3) - commision_amt_text = fields.Char(string='Commision Amount Text', compute='compute_delivery_amt_text') + + # commision_percent = fields.Float(string='Commision %', tracking=3) + commision_percent = fields.Float(string='Persentase (%)', tracking=3) + + # commision_amt = fields.Float(string='Commision Amount', tracking=3) + commision_amt = fields.Float(string='Amount', tracking=3) + + # cashback = fields.Float(string='Cashback', compute="compute_cashback") + cashback = fields.Float(string='PPh Cashback', compute="compute_cashback") + + # total_commision = fields.Float(string='Total Commision', compute="compute_cashback") + total_commision = fields.Float(string='Cashback yang dibayarkan', compute="compute_cashback") + + total_cashback = fields.Float(string='Total Cashback') + 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'), @@ -268,6 +283,20 @@ class CustomerCommision(models.Model): except: record.commision_amt_text = res + def compute_total_cashback_text(self): + tb = Terbilang() + + for record in self: + res = '' + try: + if record.total_commision > 0: + tb.parse(int(record.total_commision)) + res = tb.getresult().title() + record.total_cashback_text = f"{res} Rupiah" if res else "" + except Exception as e: + record.total_cashback_text = "" + _logger.error("Error computing cashback text: %s", str(e)) + def _compute_grouped_numbers(self): for rec in self: so_numbers = set() @@ -328,23 +357,48 @@ class CustomerCommision(models.Model): if self.total_dpp > 0 and self.commision_percent == 0: self.commision_percent = (self.commision_amt / self.total_dpp) * 100 + def compute_cashback(self): + if self.commision_type == 'cashback' and self.commision_amt > 0: + self.cashback = self.commision_amt * 0.15 + self.total_commision = self.commision_amt * 0.85 + 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): - vals['number'] = self.env['ir.sequence'].next_by_code('customer.commision') or '0' - # if vals['commision_amt'] > 0: - # commision_amt = vals['commision_amt'] - # total_dpp = vals['total_dpp'] - # commision_percent = commision_amt / total_dpp * 100 - # vals['commision_percent'] = commision_percent - result = super(CustomerCommision, self).create(vals) - return result + commision_type = vals.get('commision_type') + + if commision_type == 'cashback': + sequence_code = 'customer.commision.cashback' + elif commision_type == 'fee': + sequence_code = 'customer.commision.fee' + elif commision_type == 'rebate': + sequence_code = 'customer.commision.rebate' + else: + raise UserError('Tipe komisi tidak dikenal!') + + vals['number'] = self.env['ir.sequence'].next_by_code(sequence_code) or '0' + + return super(CustomerCommision, self).create(vals) + + # @api.model + # def create(self, vals): + # vals['number'] = self.env['ir.sequence'].next_by_code('customer.commision') or '0' + # # if vals['commision_amt'] > 0: + # # commision_amt = vals['commision_amt'] + # # total_dpp = vals['total_dpp'] + # # commision_percent = commision_amt / total_dpp * 100 + # # vals['commision_percent'] = commision_percent + # result = super(CustomerCommision, self).create(vals) + # return result def action_confirm_customer_commision(self): jakarta_tz = pytz.timezone('Asia/Jakarta') @@ -354,12 +408,12 @@ class CustomerCommision(models.Model): if not self.status or self.status == 'draft': self.status = 'pengajuan1' - elif self.status == 'pengajuan1' and self.env.user.is_sales_manager: + elif self.status == 'pengajuan1' and self.env.user.id == 19: self.status = 'pengajuan2' self.approved_by = (self.approved_by + ', ' if self.approved_by else '') + self.env.user.name self.date_approved_sales = now_naive self.position_sales = 'Sales Manager' - elif self.status == 'pengajuan2' and self.env.user.id == 19: + elif self.status == 'pengajuan2' and self.env.user.id == 216: self.status = 'pengajuan3' self.approved_by = (self.approved_by + ', ' if self.approved_by else '') + self.env.user.name self.date_approved_marketing = now_naive diff --git a/indoteknik_custom/models/coretax_fatur.py b/indoteknik_custom/models/coretax_fatur.py index 92ff1a72..ce94306f 100644 --- a/indoteknik_custom/models/coretax_fatur.py +++ b/indoteknik_custom/models/coretax_fatur.py @@ -3,6 +3,9 @@ import xml.etree.ElementTree as ET from xml.dom import minidom import base64 import re +import logging + +_logger = logging.getLogger(__name__) class CoretaxFaktur(models.Model): @@ -32,7 +35,7 @@ class CoretaxFaktur(models.Model): return cleaned_number - def generate_xml(self, invoices=None): + def generate_xml(self, invoices=None, down_payments=False): # Buat root XML root = ET.Element('TaxInvoiceBulk', { 'xmlns:xsi': "http://www.w3.org/2001/XMLSchema-instance", @@ -72,59 +75,96 @@ class CoretaxFaktur(models.Model): ET.SubElement(tax_invoice, 'BuyerEmail').text = invoice.partner_id.email or '' ET.SubElement(tax_invoice, 'BuyerIDTKU').text = buyerIDTKU - # Filter product - product_lines = invoice.invoice_line_ids.filtered( - lambda l: not l.display_type and hasattr(l, 'account_id') and - l.account_id and l.product_id and - l.account_id.id != self.DISCOUNT_ACCOUNT_ID and - l.quantity != -1 - ) + _logger.info(" invoice down_payments: %s", invoice.down_payment) + # Handle product lines based on down_payments flag + if invoice.down_payment and invoice.invoice_origin: + # Get from sale.order.line for down payment + sale_order = invoice.sale_id + if sale_order: + product_lines = sale_order.order_line.filtered( + lambda l: l.product_id and not l.is_downpayment and not l.display_type and not l.product_id.id == 229625 + ) + # Convert sale order lines to invoice-like format + converted_lines = [] + for line in product_lines: + converted_lines.append({ + 'name': line.name, + 'product_id': line.product_id, + 'price_subtotal': line.price_subtotal, + 'quantity': line.product_uom_qty, + 'price_unit': line.price_unit, + 'account_id': line.order_id.analytic_account_id or False, + }) + product_lines = converted_lines + else: + product_lines = [] + else: + # Normal case - get from invoice lines + product_lines = invoice.invoice_line_ids.filtered( + lambda l: not l.display_type and hasattr(l, 'account_id') and + l.account_id and l.product_id and + l.account_id.id != self.DISCOUNT_ACCOUNT_ID and + l.quantity != -1 + ) - # Filter discount + # Filter discount (always from invoice) discount_lines = invoice.invoice_line_ids.filtered( lambda l: not l.display_type and ( - (hasattr(l, 'account_id') and l.account_id and - l.account_id.id == self.DISCOUNT_ACCOUNT_ID) or - (l.quantity == -1) + (hasattr(l, 'account_id') and l.account_id and + l.account_id.id == self.DISCOUNT_ACCOUNT_ID) or + (l.quantity == -1) ) ) - # Calculate total product amount (before discount) - total_product_amount = sum(line.price_subtotal for line in product_lines) + # Calculate totals + total_product_amount = sum(line.get('price_subtotal', 0) if isinstance(line, dict) + else line.price_subtotal for line in product_lines) if total_product_amount == 0: total_product_amount = 1 # Avoid division by zero - # Calculate total discount amount total_discount_amount = abs(sum(line.price_subtotal for line in discount_lines)) # Tambahkan elemen ListOfGoodService list_of_good_service = ET.SubElement(tax_invoice, 'ListOfGoodService') for line in product_lines: + # Handle both dict (converted sale lines) and normal invoice lines + if isinstance(line, dict): + line_price_subtotal = line['price_subtotal'] + line_quantity = line['quantity'] + line_name = line['name'] + line_price_unit = line['price_unit'] + else: + line_price_subtotal = line.price_subtotal + line_quantity = line.quantity + line_name = line.name + line_price_unit = line.price_unit + # Calculate prorated discount - line_proportion = line.price_subtotal / total_product_amount + line_proportion = line_price_subtotal / total_product_amount line_discount = total_discount_amount * line_proportion - # unit_price = line.price_unit - subtotal = line.price_subtotal - quantity = line.quantity + subtotal = line_price_subtotal + quantity = line_quantity total_discount = round(line_discount, 2) # Calculate other tax values otherTaxBase = round(subtotal * (11 / 12), 2) if subtotal else 0 vat_amount = round(otherTaxBase * 0.12, 2) + price_per_unit = round(subtotal / quantity, 2) if quantity else 0 + # Create the line in XML good_service = ET.SubElement(list_of_good_service, 'GoodService') ET.SubElement(good_service, 'Opt').text = 'A' ET.SubElement(good_service, 'Code').text = '000000' - ET.SubElement(good_service, 'Name').text = line.name + ET.SubElement(good_service, 'Name').text = line_name ET.SubElement(good_service, 'Unit').text = 'UM.0018' - ET.SubElement(good_service, 'Price').text = str(round(subtotal / quantity, 2)) if subtotal else '0' + # ET.SubElement(good_service, 'Price').text = str(round(line_price_unit, 2)) if line_price_unit else '0' + ET.SubElement(good_service, 'Price').text = str(price_per_unit) ET.SubElement(good_service, 'Qty').text = str(quantity) ET.SubElement(good_service, 'TotalDiscount').text = str(total_discount) - ET.SubElement(good_service, 'TaxBase').text = str( - round(subtotal)) if subtotal else '0' + ET.SubElement(good_service, 'TaxBase').text = str(round(subtotal)) if subtotal else '0' ET.SubElement(good_service, 'OtherTaxBase').text = str(otherTaxBase) ET.SubElement(good_service, 'VATRate').text = '12' ET.SubElement(good_service, 'VAT').text = str(vat_amount) @@ -136,9 +176,9 @@ class CoretaxFaktur(models.Model): xml_pretty = minidom.parseString(xml_str).toprettyxml(indent=" ") return xml_pretty - def export_to_download(self, invoices): + def export_to_download(self, invoices, down_payments): # Generate XML content - xml_content = self.generate_xml(invoices) + xml_content = self.generate_xml(invoices, down_payments) # Encode content to Base64 xml_encoded = base64.b64encode(xml_content.encode('utf-8')) 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/invoice_reklas.py b/indoteknik_custom/models/invoice_reklas.py index d10d4c31..4ed7f0bf 100644 --- a/indoteknik_custom/models/invoice_reklas.py +++ b/indoteknik_custom/models/invoice_reklas.py @@ -11,7 +11,7 @@ _logger = logging.getLogger(__name__) class InvoiceReklas(models.TransientModel): _name = 'invoice.reklas' _description = "digunakan untuk reklas Uang Muka Penjualan" - reklas_id = fields.Many2one('account.move', string='Nomor CAB') + reklas_id = fields.Many2one('account.move', string='Nomor CAB', domain="[('move_type','=','entry')]") pay_amt = fields.Float(string='Yang dibayarkan') reklas_type = fields.Selection([ ('penjualan', 'Penjualan'), @@ -20,42 +20,109 @@ class InvoiceReklas(models.TransientModel): @api.onchange('reklas_type') def _onchange_reklas_type(self): - if self.reklas_type == 'penjualan': - invoices = self.env['account.move'].browse(self._context.get('active_ids', [])) - self.pay_amt = invoices.amount_total + active_ids = self._context.get('active_ids', []) + if not active_ids: + return + + move = self.env['account.move'].browse(active_ids[0]) + cab = False + + # Deteksi dari mana asal CAB + if move.move_type == 'entry': + cab = move + elif move.move_type == 'in_invoice': + if move.reklas_misc_id: + cab = move.reklas_misc_id + elif move.purchase_order_id and move.purchase_order_id.move_id: + cab = move.purchase_order_id.move_id + + # Isi field Nomor CAB jika ditemukan + if cab: + self.reklas_id = cab.id + + # Nilai yang dibayarkan harus tetap ambil dari invoice/bill + self.pay_amt = move.amount_total + + + @api.model + def default_get(self, fields): + res = super().default_get(fields) + active_ids = self._context.get('active_ids', []) + if active_ids: + move = self.env['account.move'].browse(active_ids[0]) + cab = False + + if move.move_type == 'entry': + cab = move + elif move.move_type == 'in_invoice': + if move.reklas_misc_id: + cab = move.reklas_misc_id + elif move.purchase_order_id and move.purchase_order_id.move_id: + cab = move.purchase_order_id.move_id + + if cab: + res['reklas_id'] = cab.id + + res['pay_amt'] = move.amount_total + return res + + + # @api.onchange('reklas_type') + # def _onchange_reklas_type(self): + # if self.reklas_type == 'penjualan': + # invoices = self.env['account.move'].browse(self._context.get('active_ids', [])) + # self.pay_amt = invoices.amount_total def create_reklas(self): if not self.reklas_type: raise UserError('Reklas Tipe harus diisi') if not self.reklas_id: raise UserError('Nomor CAB harus diisi') + invoices = self.env['account.move'].browse(self._context.get('active_ids', [])) current_time = datetime.now() + for invoice in invoices: - if self.reklas_type == 'penjualan': - ref_name = 'REKLAS '+self.reklas_id.name+" UANG MUKA PENJUALAN "+invoice.name+" "+invoice.partner_id.name - else: - ref_name = 'REKLAS '+self.reklas_id.name+" UANG MUKA PEMBELIAN "+invoice.name+" "+invoice.partner_id.name - if self.reklas_type == 'penjualan': - parameters_header = { - 'ref': ref_name, - 'date': current_time, - 'journal_id': 13 - } - else: - parameters_header = { - 'ref': ref_name, - 'date': current_time, - 'journal_id': 13 - } + # Ambil nama PO jika ada + po_name = invoice.purchase_order_id.name if invoice.purchase_order_id else '' + + # Susun nama referensi dengan aman + ref_name = 'REKLAS {} UANG MUKA {}{}{} {}'.format( + self.reklas_id.name or '', + 'PENJUALAN' if self.reklas_type == 'penjualan' else 'PEMBELIAN', + f" {invoice.name}" if invoice.name != self.reklas_id.name else '', + f" - {po_name}" if po_name else '', + invoice.partner_id.name or '' + ) + + # Header jurnal reklas + parameters_header = { + 'ref': ref_name, + 'date': current_time, + 'journal_id': 13 + } + + if invoice.purchase_order_id: + parameters_header['purchase_order_id'] = invoice.purchase_order_id.id account_move = request.env['account.move'].create([parameters_header]) _logger.info('Success Reklas with %s' % account_move.name) + # ✅ Set Bill asal sebagai source document + if invoice.move_type == 'in_invoice': + account_move.bill_id = invoice.id + + # Tambahkan info asal invoice ke jurnal (opsional) + account_move.invoice_origin = invoice.name + + # Simpan hubungan balik ke invoice + invoice.reklas_misc_id = account_move.id + + # Buat line debit dan kredit if self.reklas_type == 'penjualan': parameter_debit = { 'move_id': account_move.id, - 'account_id': 668, # penerimaan belum alokasi + 'account_id': 668, # penerimaan belum alokasi 'partner_id': invoice.partner_id.id, 'currency_id': 12, 'debit': self.pay_amt, @@ -71,7 +138,7 @@ class InvoiceReklas(models.TransientModel): 'credit': self.pay_amt, 'name': ref_name } - else: + else: # pembelian parameter_debit = { 'move_id': account_move.id, 'account_id': 438, @@ -90,7 +157,11 @@ class InvoiceReklas(models.TransientModel): 'credit': self.pay_amt, 'name': ref_name } + + # Simpan journal lines request.env['account.move.line'].create([parameter_debit, parameter_credit]) + + # Tampilkan hasil jurnal reklas return { 'name': _('Journal Entries'), 'view_mode': 'form', @@ -100,4 +171,3 @@ class InvoiceReklas(models.TransientModel): 'type': 'ir.actions.act_window', 'res_id': account_move.id } -
\ No newline at end of file diff --git a/indoteknik_custom/models/invoice_reklas_penjualan.py b/indoteknik_custom/models/invoice_reklas_penjualan.py index 80c3ed43..2f5ee160 100644 --- a/indoteknik_custom/models/invoice_reklas_penjualan.py +++ b/indoteknik_custom/models/invoice_reklas_penjualan.py @@ -17,43 +17,70 @@ class InvoiceReklasPenjualan(models.TransientModel): def create_reklas_penjualan(self): invoices = self.invoice_reklas_line - current_time = datetime.now() account_move_ids = [] - for invoice in invoices: - ref_name = 'REKLAS ' + invoice.reklas_id.name + " UANG MUKA PENJUALAN " + invoice.name + " " + invoice.partner_id.name + + for line in invoices: + # Ambil nama SO jika ada + so_name = line.sale_id.name if line.sale_id else '' + + # Susun referensi nama jurnal + ref_name = 'REKLAS {} UANG MUKA PENJUALAN {}{} {}'.format( + line.reklas_id.name or '', + line.name or '', + f" - {so_name}" if so_name else '', + line.partner_id.name or '' + ) + + # Header jurnal parameters_header = { 'ref': ref_name, 'date': current_time, - 'journal_id': 13 + 'journal_id': 13, + # ⬇️ Tambahkan jika tahu invoice asal (name = ID Bill) + 'bill_id': int(line.name) if line.name and line.name.isdigit() else False, } account_move = self.env['account.move'].create([parameters_header]) _logger.info('Success Reklas with %s' % account_move.name) - parameter_debit = { + # Simpan info asal (optional) + account_move.invoice_origin = line.name + + # Simpan juga ke `reklas_misc_id` jika ditemukan invoice valid + if line.name and line.name.isdigit(): + invoice_id = self.env['account.move'].browse(int(line.name)) + if invoice_id.exists(): + invoice_id.reklas_misc_id = account_move.id + + # Buat debit kredit line + debit_line = { 'move_id': account_move.id, - 'account_id': 668, # uang muka penjualan - 'partner_id': invoice.partner_id.id, + 'account_id': 668, # akun penerimaan belum alokasi + 'partner_id': line.partner_id.id, 'currency_id': 12, - 'debit': invoice.pay_amt, + 'debit': line.pay_amt, 'credit': 0, 'name': ref_name } - parameter_credit = { + credit_line = { 'move_id': account_move.id, - 'account_id': 395, - 'partner_id': invoice.partner_id.id, + 'account_id': 395, # akun pengurang + 'partner_id': line.partner_id.id, 'currency_id': 12, 'debit': 0, - 'credit': invoice.pay_amt, + 'credit': line.pay_amt, 'name': ref_name } - self.env['account.move.line'].create([parameter_debit, parameter_credit]) + + self.env['account.move.line'].create([debit_line, credit_line]) account_move_ids.append(account_move.id) - invoice.unlink() - - self.unlink() + + line.unlink() # bersihkan line setelah selesai + + self.unlink() # hapus wizard utama setelah selesai + + # Tampilkan hasil jurnal reklas return { 'name': _('Journal Entries'), 'view_mode': 'tree,form', @@ -63,6 +90,7 @@ class InvoiceReklasPenjualan(models.TransientModel): 'domain': [('id', 'in', account_move_ids)], } + class InvoiceReklasPenjualanLine(models.TransientModel): _name = 'invoice.reklas.penjualan.line' _description = "digunakan untuk reklas Uang Muka Penjualan" diff --git a/indoteknik_custom/models/mail_mail.py b/indoteknik_custom/models/mail_mail.py index 82b1fcca..792b97b7 100644 --- a/indoteknik_custom/models/mail_mail.py +++ b/indoteknik_custom/models/mail_mail.py @@ -1,12 +1,27 @@ from odoo import fields, models, api, _ +from datetime import timedelta +import logging +_logger = logging.getLogger(__name__) class MailMail(models.Model): _inherit = 'mail.mail' + @api.model def retry_send_mail(self): - mails = self.env['mail.mail'].search([ - ('state', 'in', ['exception', 'cancel']), + now = fields.Datetime.now() + seven_days_ago = now - timedelta(days=7) + + # Filter hanya email gagal dalam 7 hari terakhir + mails = self.search([ + ('state', 'in', ['exception']), + ('create_date', '>=', seven_days_ago), + ('create_date', '<=', now), ], limit=250) + + _logger.info("Found %s failed emails in last 7 days to retry.", len(mails)) + for mail in mails: + _logger.info("Retrying email ID %s - To: %s - Subject: %s", + mail.id, mail.email_to, mail.subject) mail.state = 'outgoing' diff --git a/indoteknik_custom/models/manufacturing.py b/indoteknik_custom/models/manufacturing.py index 715d8513..aea01362 100644 --- a/indoteknik_custom/models/manufacturing.py +++ b/indoteknik_custom/models/manufacturing.py @@ -11,6 +11,9 @@ class Manufacturing(models.Model): def action_confirm(self): if self._name != 'mrp.production': return super(Manufacturing, self).action_confirm() + + if not self.env.user.is_purchasing_manager: + raise UserError("Hanya bisa di confirm oleh Purchasing Manager") # if self.location_src_id.id != 75: # raise UserError('Component Location hanya bisa di AS/Stock') diff --git a/indoteknik_custom/models/mrp_production.py b/indoteknik_custom/models/mrp_production.py index 14821f27..7977bdf7 100644 --- a/indoteknik_custom/models/mrp_production.py +++ b/indoteknik_custom/models/mrp_production.py @@ -110,8 +110,9 @@ class MrpProduction(models.Model): 'picking_type_id': 28, # indoteknik bandengan receipts 'date_order': current_time, 'product_bom_id': self.product_id.id, - # 'sale_order_id': self.sale_order_id.id, - 'note_description': 'from Manufacturing Order' + 'sale_order_id': self.sale_order.id, + 'manufacturing_id': self.id, + 'note_description': 'from Manufacturing Order', } domain = [ @@ -155,6 +156,8 @@ class MrpProduction(models.Model): 'order_id': new_po.id }]) + new_po.button_confirm() + self.is_po = True return po_ids 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/product_template.py b/indoteknik_custom/models/product_template.py index 3bb54f44..f59bea6b 100755 --- a/indoteknik_custom/models/product_template.py +++ b/indoteknik_custom/models/product_template.py @@ -349,6 +349,9 @@ class ProductTemplate(models.Model): 'search_key':[item_code], } response = requests.post(url, headers=headers, json=json_data) + if response.status_code != 200: + return 0 + datas = json.loads(response.text)['data'] qty = 0 for data in datas: @@ -1146,7 +1149,7 @@ class ProductProduct(models.Model): def _get_qty_free_bandengan(self): for product in self: - qty_free = product.qty_onhand_bandengan - product.qty_outgoing_bandengan + qty_free = product.qty_onhand_bandengan - product.qty_outgoing_bandengan - product.qty_outgoing_mo_bandengan product.qty_free_bandengan = qty_free def _get_max_qty_reordering_rule(self): diff --git a/indoteknik_custom/models/purchase_order.py b/indoteknik_custom/models/purchase_order.py index cbfd4acd..45134939 100755 --- a/indoteknik_custom/models/purchase_order.py +++ b/indoteknik_custom/models/purchase_order.py @@ -17,6 +17,7 @@ _logger = logging.getLogger(__name__) class PurchaseOrder(models.Model): _inherit = 'purchase.order' + vcm_id = fields.Many2one('tukar.guling.po', string='Doc VCM', readonly=True, compute='_has_vcm', copy=False) order_sales_match_line = fields.One2many('purchase.order.sales.match', 'purchase_order_id', string='Sales Match Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True) sale_order_id = fields.Many2one('sale.order', string='Sale Order') procurement_status = fields.Char(string='Procurement Status', compute='get_procurement_status', readonly=True) @@ -65,7 +66,7 @@ class PurchaseOrder(models.Model): sale_order = fields.Char(string='Sale Order') matches_so = fields.Many2many('sale.order', string='Matches SO', compute='_compute_matches_so') is_create_uangmuka = fields.Boolean(string='Uang Muka?') - move_id = fields.Many2one('account.move', string='Account Move') + move_id = fields.Many2one('account.move', string='Journal Entries Uang Muka', domain=[('move_type', '=', 'entry')]) logbook_bill_id = fields.Many2one('report.logbook.bill', string='Logbook Bill') status_printed = fields.Selection([ ('not_printed', 'Belum Print'), @@ -89,6 +90,112 @@ class PurchaseOrder(models.Model): store_name = fields.Char(string='Nama Toko') purchase_order_count = fields.Integer('Purchase Order Count', related='partner_id.purchase_order_count') + is_cab_visible = fields.Boolean(string='Tampilkan Tombol CAB', compute='_compute_is_cab_visible') + + # picking_ids = fields.One2many('stock.picking', 'purchase_id', string='Pickings') + + bu_related_count = fields.Integer( + string="BU Related Count", + compute='_compute_bu_related_count' + ) + manufacturing_id = fields.Many2one('mrp.production', string='Manufacturing Orders') + + def _has_vcm(self): + if self.id: + self.vcm_id = self.env['tukar.guling.po'].search([('origin', '=', self.name)], limit=1) + + @api.depends('name') + def _compute_bu_related_count(self): + StockPicking = self.env['stock.picking'] + for order in self: + if not order.name: + order.bu_related_count = 0 + continue + + # Ambil semua BU awal dari PO + base_bu = StockPicking.search([ + ('name', 'ilike', 'BU/'), + ('origin', 'ilike', order.name) + ]) + + all_bu = base_bu + seen_names = set(base_bu.mapped('name')) + + # Loop rekursif untuk mencari seluruh return BU + while True: + next_bu = StockPicking.search([ + ('name', 'ilike', 'BU/'), + ('origin', 'in', ['Return of %s' % name for name in seen_names]) + ]) + next_names = set(next_bu.mapped('name')) + + if not next_names - seen_names: + break + + all_bu |= next_bu + seen_names |= next_names + + order.bu_related_count = len(all_bu) + + + def action_view_related_bu(self): + self.ensure_one() + + StockPicking = self.env['stock.picking'] + + # Step 1: cari semua BU pertama (PUT, INT) yang berasal dari PO ini + base_bu = StockPicking.search([ + ('name', 'ilike', 'BU/'), + ('origin', 'ilike', self.name) + ]) + + all_bu = base_bu + seen_names = set(base_bu.mapped('name')) + + # Step 2: Loop rekursif cari BU dengan origin 'Return of {name}' + while True: + next_bu = StockPicking.search([ + ('name', 'ilike', 'BU/'), + ('origin', 'in', ['Return of %s' % name for name in seen_names]) + ]) + next_names = set(next_bu.mapped('name')) + + if not next_names - seen_names: + break + + all_bu |= next_bu + seen_names |= next_names + + return { + 'name': 'Related BU (INT/PRT/PUT/VRT)', + 'type': 'ir.actions.act_window', + 'res_model': 'stock.picking', + 'view_mode': 'tree,form', + 'target': 'current', + 'domain': [('id', 'in', list(all_bu.ids))], + } + + + @api.depends('move_id.state') + def _compute_is_cab_visible(self): + for order in self: + move = order.move_id + order.is_cab_visible = bool(move and move.state == 'posted') + + def action_view_journal_uangmuka(self): + self.ensure_one() + if not self.move_id: + raise UserError("Journal Uang Muka belum tersedia.") + + return { + 'type': 'ir.actions.act_window', + 'name': 'Journal Entry', + 'res_model': 'account.move', + 'res_id': self.move_id.id, + 'view_mode': 'form', + 'target': 'current', + } + # cek payment term def _check_payment_term(self): _logger.info("Check Payment Term Terpanggil") @@ -430,6 +537,18 @@ class PurchaseOrder(models.Model): 'company_id': self.company_id.id, 'payment_schedule': payment_schedule } + + receipt = self.env['stock.picking'].search([ + ('purchase_id', '=', self.id), + ('state', '=', 'done'), + ('picking_type_code', '=', 'incoming'), + ('date_done', '!=', False) + ], order='date_done desc', limit=1) + + if receipt: + invoice_vals['invoice_date'] = receipt.date_done + invoice_vals['date'] = receipt.date_done + return invoice_vals def _compute_matches_so(self): @@ -558,6 +677,13 @@ class PurchaseOrder(models.Model): for order in self: order.has_active_invoice = any(invoice.state != 'cancel' for invoice in order.invoice_ids) + # def _compute_has_active_invoice(self): + # for order in self: + # related_invoices = order.invoice_ids.filtered( + # lambda inv: inv.purchase_order_id.id == order.id and inv.move_type == 'in_invoice' and inv.state != 'cancel' + # ) + # order.has_active_invoice = bool(related_invoices) + def add_product_to_pricelist(self): i = 0 for line in self.order_line: @@ -808,16 +934,20 @@ class PurchaseOrder(models.Model): # test = line.product_uom_qty # test2 = line.product_id.plafon_qty # test3 = test2 + line.product_uom_qty - if line.product_uom_qty > line.product_id.plafon_qty + line.product_uom_qty and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'): - raise UserError('Product '+line.product_id.name+' melebihi plafon, harus Approval MD') + if line.product_uom_qty > line.product_id.plafon_qty + line.product_uom_qty and not self.env.user.id == 21: + raise UserError('Product '+line.product_id.name+' melebihi plafon, harus Approval Rafly') def check_different_vendor_so_po(self): vendor_po = self.partner_id.id for line in self.order_line: if not line.so_line_id: continue - if line.so_line_id.vendor_id.id != vendor_po and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'): - raise UserError("Produk "+line.product_id.name+" memiliki vendor berbeda dengan SO (Vendor PO: "+str(self.partner_id.name)+", Vendor SO: "+str(line.so_line_id.vendor_id.name)+")") + if line.so_line_id.vendor_id.id != vendor_po: + self.env.user.notify_danger( + title='WARNING!!!', + message="Produk "+line.product_id.name+" memiliki vendor berbeda dengan SO (Vendor PO: "+str(self.partner_id.name)+", Vendor SO: "+str(line.so_line_id.vendor_id.name)+")", + sticky=True + ) def button_confirm(self): # self._check_payment_term() # check payment term @@ -830,23 +960,23 @@ class PurchaseOrder(models.Model): if self.amount_untaxed >= 50000000 and not self.env.user.id == 21: raise UserError("Hanya Rafly Hanggara yang bisa approve") - if self.total_percent_margin < self.total_so_percent_margin and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser') and not self.env.user.is_leader: + if self.total_percent_margin < self.total_so_percent_margin: self.env.user.notify_danger( title='WARNING!!!', message='Beda Margin dengan Sale Order', sticky=True ) - if len(self.order_sales_match_line) == 0 and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser') and not self.env.user.is_leader: - self.env.user.notify_danger( - title='WARNING!!!', - message='Tidak ada matches SO', - sticky=True - ) + # if len(self.order_sales_match_line) == 0 and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser') and not self.env.user.is_leader: + # self.env.user.notify_danger( + # title='WARNING!!!', + # message='Tidak ada matches SO', + # sticky=True + # ) if not self.from_apo: - if not self.matches_so and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser') and not self.env.user.is_leader: - raise UserError("Tidak ada link dengan SO, harus approval Merchandise") + if (not self.matches_so or not self.sale_order_id) and not self.env.user.is_purchasing_manager and not self.env.user.is_leader and not self.manufacturing_id: + raise UserError("Tidak ada link dengan SO, harus di confirm oleh Purchasing Manager") send_email = False if not self.not_update_purchasepricelist: @@ -891,6 +1021,12 @@ class PurchaseOrder(models.Model): if self.product_bom_id: self._remove_product_bom() + # Tambahan: redirect ke BU hanya untuk single PO yang berhasil dikonfirmasi + _logger.info("Jumlah PO: %s | State: %s", len(self), self.state) + if len(self) == 1: + _logger.info("Redirecting ke BU") + return self.action_view_related_bu() + return res def _remove_product_bom(self): @@ -912,7 +1048,7 @@ class PurchaseOrder(models.Model): for line in self.order_line: if line.taxes_id != reference_taxes: - raise UserError("PPN harus sama untuk semua baris pada line.") + raise UserError(f"PPN harus sama untuk semua baris pada line {line.product_id.name}") def check_data_vendor(self): vendor = self.partner_id @@ -998,7 +1134,7 @@ class PurchaseOrder(models.Model): self.approval_status_unlock = 'approvedFinance' else: raise UserError("Bisa langsung Confirm, menunggu persetujuan Finance jika ingin unlock PO") - elif self.env.user.is_leader or self.env.user.has_group('indoteknik_custom.group_role_merchandiser'): + elif self.env.user.is_leader or self.env.user.has_group('indoteknik_custom.group_role_purchasing'): raise UserError("Bisa langsung Confirm") elif self.total_percent_margin == self.total_so_percent_margin and self.matches_so and not greater_than_plafon and not different_vendor_message: raise UserError("Bisa langsung Confirm") diff --git a/indoteknik_custom/models/purchase_order_sales_match.py b/indoteknik_custom/models/purchase_order_sales_match.py index 0bd0092b..b18864f3 100644 --- a/indoteknik_custom/models/purchase_order_sales_match.py +++ b/indoteknik_custom/models/purchase_order_sales_match.py @@ -28,6 +28,20 @@ class PurchaseOrderSalesMatch(models.Model): purchase_price_po = fields.Float('Purchase Price PO', compute='_compute_purchase_price_po') purchase_line_id = fields.Many2one('purchase.order.line', string='Purchase Line', compute='_compute_purchase_line_id') hold_outgoing_so = fields.Boolean(string='Hold Outgoing SO', related='sale_id.hold_outgoing') + bu_pick = fields.Many2one('stock.picking', string='BU Pick', compute='compute_bu_pick') + + def compute_bu_pick(self): + for rec in self: + stock_move = self.env['stock.move'].search([ + ('reference', 'ilike', 'BU/PICK'), + ('state', 'in', ['confirmed','waiting','partially_available']), + ('product_id', '=', rec.product_id.id), + ('sale_line_id', '=', rec.sale_line_id.id), + ]) + if stock_move: + rec.bu_pick = stock_move.picking_id.id + else: + rec.bu_pick = None def _compute_purchase_line_id(self): for line in self: diff --git a/indoteknik_custom/models/purchasing_job.py b/indoteknik_custom/models/purchasing_job.py index ea2f46cb..db733b5a 100644 --- a/indoteknik_custom/models/purchasing_job.py +++ b/indoteknik_custom/models/purchasing_job.py @@ -26,6 +26,46 @@ class PurchasingJob(models.Model): purchase_representative_id = fields.Many2one('res.users', string="Purchase Representative", readonly=True) note = fields.Char(string="Note Detail") date_po = fields.Datetime(string='Date PO', copy=False) + so_number = fields.Text(string='SO Number', copy=False) + check_pj = fields.Boolean(compute='_get_check_pj', string='Linked') + + def action_open_job_detail(self): + self.ensure_one() + Seen = self.env['purchasing.job.seen'] + seen = Seen.search([ + ('user_id', '=', self.env.uid), + ('product_id', '=', self.product_id.id) + ], limit=1) + + if seen: + seen.so_snapshot = self.so_number + seen.seen_date = fields.Datetime.now() + else: + Seen.create({ + 'user_id': self.env.uid, + 'product_id': self.product_id.id, + 'so_snapshot': self.so_number, + }) + + return { + 'name': 'Purchasing Job Detail', + 'type': 'ir.actions.act_window', + 'res_model': 'v.purchasing.job', + 'res_id': self.id, + 'view_mode': 'form', + 'target': 'current', + } + + + @api.depends('so_number') + def _get_check_pj(self): + for rec in self: + seen = self.env['purchasing.job.seen'].search([ + ('user_id', '=', self.env.uid), + ('product_id', '=', rec.product_id.id) + ], limit=1) + rec.check_pj = bool(seen and seen.so_snapshot == rec.so_number) + def unlink(self): # Example: Delete related records from the underlying model @@ -66,6 +106,7 @@ class PurchasingJob(models.Model): max(pjs.status_apo::text) AS status_apo, max(pjs.note::text) AS note, max(pjs.date_po::text) AS date_po, + pmp.so_number, CASE WHEN pmp.brand IN ('Tekiro', 'RYU', 'Rexco', 'RYU (Sparepart)') THEN 27 WHEN sub.vendor_id = 9688 THEN 397 @@ -83,7 +124,7 @@ class PurchasingJob(models.Model): group by vso.product_id ) sub ON sub.product_id = pmp.product_id WHERE pmp.action = 'kurang'::text AND sub.vendor_id IS NOT NULL - GROUP BY pmp.product_id, pmp.brand, pmp.item_code, pmp.product, pmp.action, sub.vendor_id; + GROUP BY pmp.product_id, pmp.brand, pmp.item_code, pmp.product, pmp.action, sub.vendor_id, pmp.so_number; """ % self._table) def open_form_multi_generate_request_po(self): @@ -197,3 +238,17 @@ class OutstandingSales(models.Model): and sp.name like '%OUT%' ) """) + +class PurchasingJobSeen(models.Model): + _name = 'purchasing.job.seen' + _description = 'User Seen SO Snapshot' + _rec_name = 'product_id' + + user_id = fields.Many2one('res.users', required=True, ondelete='cascade') + product_id = fields.Many2one('product.product', required=True, ondelete='cascade') + so_snapshot = fields.Text("Last Seen SO") + seen_date = fields.Datetime(default=fields.Datetime.now) + + _sql_constraints = [ + ('user_product_unique', 'unique(user_id, product_id)', 'User already tracked this product.') + ] diff --git a/indoteknik_custom/models/refund_sale_order.py b/indoteknik_custom/models/refund_sale_order.py new file mode 100644 index 00000000..11bfd07f --- /dev/null +++ b/indoteknik_custom/models/refund_sale_order.py @@ -0,0 +1,653 @@ +from odoo import fields, models, api, _ +from datetime import date, datetime +from terbilang import Terbilang +from odoo.exceptions import UserError, ValidationError +from markupsafe import escape as html_escape +import pytz +from lxml import etree + + +class RefundSaleOrder(models.Model): + _name = 'refund.sale.order' + _description = 'Refund Sales Order' + _inherit = ['mail.thread'] + _rec_name = 'name' + + name = fields.Char(string='Refund Number', default='New', copy=False, readonly=True) + note_refund = fields.Text(string='Note Refund') + sale_order_ids = fields.Many2many('sale.order', string='Sales Order Numbers') + uang_masuk = fields.Float(string='Uang Masuk', required=True) + total_invoice = fields.Float(string='Total Invoice') + ongkir = fields.Float(string='Ongkir', required=True, default=0.0) + amount_refund = fields.Float(string='Total Refund', required=True) + amount_refund_text = fields.Char(string='Total Refund Text', compute='_compute_refund_text') + user_ids = fields.Many2many('res.users', string='Salespersons', compute='_compute_user_ids', domain=[('active', 'in', [True, False])]) + create_uid = fields.Many2one('res.users', string='Created By', readonly=True) + created_date = fields.Date(string='Tanggal Request Refund', readonly=True) + status = fields.Selection([ + ('draft', 'Draft'), + ('pengajuan1', 'Approval Sales Manager'), + ('pengajuan2', 'Approval AR'), + ('pengajuan3', 'Approval Pimpinan'), + ('reject', 'Cancel'), + ('refund', 'Approved') + ], string='Status Refund', default='draft', tracking=True) + + status_payment = fields.Selection([ + ('pending', 'Pending'), + ('reject', 'Cancel'), + ('done', 'Payment') + ], string='Status Payment', default='pending', tracking=True) + + reason_reject = fields.Text(string='Reason Cancel') + refund_date = fields.Date(string='Tanggal Refund') + invoice_ids = fields.Many2many('account.move', string='Invoices') + bank = fields.Char(string='Bank', required=True) + account_name = fields.Char(string='Account Name', required=True) + account_no = fields.Char(string='Account No', required=True) + finance_note = fields.Text(string='Finance Note') + invoice_names = fields.Html(string="Group Invoice Number", compute="_compute_invoice_names") + so_names = fields.Html(string="Group SO Number", compute="_compute_so_names") + + refund_type = fields.Selection([ + ('barang_kosong_sebagian', 'Refund Barang Kosong Sebagian'), + ('barang_kosong', 'Refund Barang Kosong Full'), + ('uang', 'Refund Lebih Bayar'), + ('retur_half', 'Refund Retur Sebagian'), + ('retur', 'Refund Retur Full'), + ('lainnya', 'Lainnya') + ], string='Refund Type', required=True) + + refund_type_display = fields.Char(string="Refund Type Label", compute="_compute_refund_type_display") + + line_ids = fields.One2many('refund.sale.order.line', 'refund_id', string='Refund Lines') + invoice_line_ids = fields.One2many( + comodel_name='account.move.line', + inverse_name='move_id', + string='Invoice Lines', + compute='_compute_invoice_lines' + ) + + approved_by = fields.Text(string='Approved By', readonly=True) + date_approved_sales = fields.Datetime(string='Date Approved (Sales Manager)', readonly=True) + date_approved_ar = fields.Datetime(string='Date Approved (AR)', readonly=True) + date_approved_pimpinan = fields.Datetime(string='Date Approved (Pimpinan)', readonly=True) + position_sales = fields.Char(string='Position Sales', readonly=True) + position_ar = fields.Char(string='Position AR', readonly=True) + position_pimpinan = fields.Char(string='Position Pimpinan', readonly=True) + + partner_id = fields.Many2one( + 'res.partner', + string='Customer', + required=True + ) + advance_move_names = fields.Html(string="Group Journal SO", compute="_compute_advance_move_names") + uang_masuk_type = fields.Selection([ + ('pdf', 'PDF'), + ('image', 'Image'), + ], string="Attachment Type", default='image') + bukti_refund_type = fields.Selection([ + ('pdf', 'PDF'), + ('image', 'Image'), + ], string="Attachment Type", default='image') + bukti_uang_masuk_image = fields.Binary(string="Upload Bukti Uang Masuk") + bukti_transfer_refund_image = fields.Binary(string="Upload Bukti Transfer Refund") + bukti_uang_masuk_pdf = fields.Binary(string="Upload Bukti Uang Masuk") + bukti_transfer_refund_pdf = fields.Binary(string="Upload Bukti Transfer Refund") + journal_refund_move_id = fields.Many2one( + 'account.move', + string='Journal Refund', + compute='_compute_journal_refund_move_id', + ) + journal_refund_state = fields.Selection( + related='journal_refund_move_id.state', + string='Journal Refund State', + ) + + is_locked = fields.Boolean(string="Locked", compute="_compute_is_locked") + sale_order_names_jasper = fields.Char(string='Sales Order List', compute='_compute_order_invoice_names') + invoice_names_jasper = fields.Char(string='Invoice List', compute='_compute_order_invoice_names') + + + + @api.depends('refund_type') + def _compute_refund_type_display(self): + for rec in self: + rec.refund_type_display = dict(self.fields_get(allfields=['refund_type'])['refund_type']['selection']).get(rec.refund_type, '') + + + @api.model + def create(self, vals): + allowed_user_ids = [23, 19, 688, 7] + if not ( + self.env.user.has_group('indoteknik_custom.group_role_sales') or + self.env.user.has_group('indoteknik_custom.group_role_fat') or + self.env.user.id not in allowed_user_ids + ): + raise UserError("❌ Hanya user Sales dan Finance yang boleh membuat refund.") + + + if vals.get('name', 'New') == 'New': + vals['name'] = self.env['ir.sequence'].next_by_code('refund.sale.order') or 'New' + + vals['created_date'] = fields.Date.context_today(self) + vals['create_uid'] = self.env.user.id + + if 'sale_order_ids' in vals: + so_cmd = vals['sale_order_ids'] + so_ids = so_cmd[0][2] if so_cmd and so_cmd[0][0] == 6 else [] + if so_ids: + sale_orders = self.env['sale.order'].browse(so_ids) + vals['partner_id'] = sale_orders[0].partner_id.id + + invoices = sale_orders.mapped('invoice_ids').filtered( + lambda inv: inv.move_type in ['out_invoice', 'out_refund'] and inv.state != 'cancel' + ) + if invoices: + vals['invoice_ids'] = [(6, 0, invoices.ids)] + + + refund_type = vals.get('refund_type') + invoice_ids_data = vals.get('invoice_ids', []) + invoice_ids = invoice_ids_data[0][2] if invoice_ids_data and invoice_ids_data[0][0] == 6 else [] + + if invoice_ids and refund_type and refund_type not in ['uang', 'barang_kosong_sebagian', 'barang_kosong', 'retur_half']: + raise UserError("Refund type Hanya Bisa Lebih Bayar, Barang Kosong Sebagian, atau Retur Sebagian jika ada invoice") + + if not invoice_ids and refund_type and refund_type in ['uang', 'barang_kosong_sebagian', 'barang_kosong', 'retur_half']: + raise UserError("Refund type Lebih Bayar, Barang Kosong Sebagian, atau Retur Sebagian Hanya Bisa dipilih Jika Ada Invoice") + + + if not so_ids and refund_type != 'lainnya': + raise ValidationError("Jika tidak ada Sales Order yang dipilih, maka Tipe Refund hanya boleh 'Lainnya'.") + + refund = refund_type in ['retur', 'retur_half'] + if refund and so_ids: + so = self.env['sale.order'].browse(so_ids) + pickings = self.env['stock.picking'].search([ + ('state', '=', 'done'), + ('picking_type_id', '=', 73), + ('sale_id', 'in', so_ids) + ]) + if not pickings: + raise ValidationError(f"SO {', '.join(so.mapped('name'))} tidak melakukan retur barang.") + + if refund_type == 'retur_half' and not invoice_ids: + raise ValidationError(f"SO {', '.join(so.mapped('name'))} belum memiliki invoice untuk Retur Sebagian.") + + total_invoice = sum(self.env['account.move'].browse(invoice_ids).mapped('amount_total')) if invoice_ids else 0.0 + uang_masuk = vals.get('uang_masuk', 0.0) + ongkir = vals.get('ongkir', 0.0) + pengurangan = total_invoice + ongkir + + if uang_masuk > pengurangan: + vals['amount_refund'] = uang_masuk - pengurangan + else: + raise UserError("Uang masuk harus lebih besar dari total invoice + ongkir untuk melakukan refund") + + return super().create(vals) + + + def write(self, vals): + allowed_user_ids = [23, 19, 688, 7] + if not ( + self.env.user.has_group('indoteknik_custom.group_role_sales') or + self.env.user.has_group('indoteknik_custom.group_role_fat') or + self.env.user.id in allowed_user_ids + ): + raise UserError("❌ Hanya user Sales dan Finance yang boleh mengedit refund.") + + for rec in self: + if 'sale_order_ids' in vals: + so_commands = vals['sale_order_ids'] + so_ids = [] + for cmd in so_commands: + if cmd[0] == 6: + so_ids = cmd[2] + elif cmd[0] == 4: + so_ids.append(cmd[1]) + elif cmd[0] == 3: + if cmd[1] in so_ids: + so_ids.remove(cmd[1]) + + if so_ids: + sale_orders = self.env['sale.order'].browse(so_ids) + vals['partner_id'] = sale_orders[0].partner_id.id + + sale_orders = self.env['sale.order'].browse(so_ids) + + valid_invoices = sale_orders.mapped('invoice_ids').filtered( + lambda inv: inv.move_type in ['out_invoice', 'out_refund'] and inv.state != 'cancel' + ) + vals['invoice_ids'] = [(6, 0, valid_invoices.ids)] + vals['ongkir'] = sum(so.delivery_amt or 0.0 for so in sale_orders) + else: + so_ids = rec.sale_order_ids.ids + + sale_orders = self.env['sale.order'].browse(so_ids) + + + refund_type = vals.get('refund_type', rec.refund_type) + + if not so_ids and refund_type != 'lainnya': + raise ValidationError("Jika tidak ada Sales Order yang dipilih, maka Tipe Refund hanya boleh 'Lainnya'.") + + + invoice_ids = vals.get('invoice_ids', False) + if invoice_ids: + final_invoice_ids = [] + for cmd in invoice_ids: + if cmd[0] == 6: + final_invoice_ids = cmd[2] + elif cmd[0] == 4: + final_invoice_ids.append(cmd[1]) + invoice_ids = final_invoice_ids + else: + invoice_ids = rec.invoice_ids.ids + + if invoice_ids and vals.get('refund_type', rec.refund_type) not in ['uang', 'barang_kosong_sebagian', 'barang_kosong', 'retur_half']: + raise UserError("Refund type Hanya Bisa Lebih Bayar, Barang Kosong Sebagian, atau Retur Sebagian jika ada invoice") + + if not invoice_ids and vals.get('refund_type', rec.refund_type) in ['uang', 'barang_kosong_sebagian', 'barang_kosong', 'retur_half']: + raise UserError("Refund type Lebih Bayar, Barang Kosong Sebagian, atau Retur Sebagian Hanya Bisa dipilih Jika Ada Invoice") + + if refund_type in ['retur', 'retur_half'] and so_ids: + so = self.env['sale.order'].browse(so_ids) + pickings = self.env['stock.picking'].search([ + ('state', '=', 'done'), + ('picking_type_id', '=', 73), + ('sale_id', 'in', so_ids) + ]) + + if not pickings: + raise ValidationError(f"SO {', '.join(so.mapped('name'))} tidak melakukan retur barang.") + + if refund_type == 'retur_half' and not invoice_ids: + raise ValidationError(f"SO {', '.join(so.mapped('name'))} belum memiliki invoice untuk retur sebagian.") + + if any(field in vals for field in ['uang_masuk', 'invoice_ids', 'ongkir', 'sale_order_ids']): + total_invoice = sum(self.env['account.move'].browse(invoice_ids).mapped('amount_total')) + uang_masuk = vals.get('uang_masuk', rec.uang_masuk) + ongkir = vals.get('ongkir', rec.ongkir) + + if uang_masuk <= (total_invoice + ongkir): + raise UserError("Uang masuk harus lebih besar dari total invoice + ongkir") + vals['amount_refund'] = uang_masuk - (total_invoice + ongkir) + + if vals.get('status') == 'refund' and not vals.get('refund_date'): + vals['refund_date'] = fields.Date.context_today(self) + + return super().write(vals) + + @api.depends('status_payment') + def _compute_is_locked(self): + for rec in self: + rec.is_locked = rec.status_payment in ['done', 'reject'] + + @api.depends('sale_order_ids.name', 'invoice_ids.name') + def _compute_order_invoice_names(self): + for rec in self: + rec.sale_order_names_jasper = ', '.join(rec.sale_order_ids.mapped('name')) or '' + rec.invoice_names_jasper = ', '.join(rec.invoice_ids.mapped('name')) or '' + + @api.depends('sale_order_ids') + def _compute_advance_move_names(self): + for rec in self: + move_links = [] + moves = self.env['account.move'].search([ + ('sale_id', 'in', rec.sale_order_ids.ids), + ('journal_id', '=', 11), + ('state', '=', 'posted') + ]) + for move in moves: + url = f"/web#id={move.id}&model=account.move&view_type=form" + name = html_escape(move.name or 'Unnamed') + move_links.append(f'<a href="{url}" target="_blank">{name}</a>') + rec.advance_move_names = ', '.join(move_links) if move_links else "-" + + @api.depends('sale_order_ids.user_id') + def _compute_user_ids(self): + for rec in self: + user_ids = list({so.user_id.id for so in rec.sale_order_ids if so.user_id}) + rec.user_ids = [(6, 0, user_ids)] + + @api.onchange('sale_order_ids') + def _onchange_sale_order_ids(self): + self.invoice_ids = [(5, 0, 0)] + self.line_ids = [(5, 0, 0)] + self.ongkir = 0.0 + all_invoices = self.env['account.move'] + total_invoice = 0.0 + + for so in self.sale_order_ids: + self.ongkir += so.delivery_amt or 0.0 + valid_invoices = so.invoice_ids.filtered( + lambda inv: inv.move_type in ['out_invoice', 'out_refund'] and inv.state != 'cancel' + ) + all_invoices |= valid_invoices + total_invoice += sum(valid_invoices.mapped('amount_total')) + + + self.invoice_ids = all_invoices + self.total_invoice = total_invoice + self.refund_type = 'uang' if all_invoices else False + + pengurangan = total_invoice + self.ongkir + if self.uang_masuk > pengurangan: + self.amount_refund = self.uang_masuk - pengurangan + else: + self.amount_refund = 0.0 + + if self.sale_order_ids: + self.partner_id = self.sale_order_ids[0].partner_id + + + @api.onchange('refund_type') + def _onchange_refund_type(self): + self.line_ids = [(5, 0, 0)] + if self.refund_type in ['barang_kosong_sebagian', 'barang_kosong'] and self.sale_order_ids: + line_vals = [] + for so in self.sale_order_ids: + for line in so.order_line: + if line.qty_delivered == 0: + line_vals.append((0, 0, { + 'product_id': line.product_id.id, + 'quantity': line.product_uom_qty, + 'reason': '', + })) + + self.line_ids = line_vals + + elif self.refund_type in ['retur', 'retur_half'] and self.sale_order_ids: + line_vals = [] + StockPicking = self.env['stock.picking'] + for so in self.sale_order_ids: + pickings = StockPicking.search([ + ('state', '=', 'done'), + ('picking_type_id', '=', 73), + ('sale_id', 'in', so.ids) + ]) + + for picking in pickings: + for move in picking.move_lines: + line_vals.append((0, 0, { + 'product_id': move.product_id.id, + 'quantity': move.product_uom_qty, + 'reason': '', + })) + self.line_ids = line_vals + + + @api.depends('invoice_ids') + def _compute_invoice_lines(self): + for rec in self: + lines = self.env['account.move.line'] + for inv in rec.invoice_ids: + lines |= inv.invoice_line_ids + rec.invoice_line_ids = lines + + @api.depends('amount_refund') + def _compute_refund_text(self): + tb = Terbilang() + for record in self: + res = '' + try: + if record.amount_refund > 0: + tb.parse(int(record.amount_refund)) + res = tb.getresult().title() + record.amount_refund_text = res + ' Rupiah' + except: + record.amount_refund_text = '' + + def unlink(self): + not_draft = self.filtered(lambda r: r.status != 'draft') + if not_draft: + names = ', '.join(not_draft.mapped('name')) + raise UserError(f"Refund hanya bisa dihapus jika statusnya masih draft.\nTidak bisa hapus: {names}") + return super().unlink() + + @api.depends('invoice_ids') + def _compute_invoice_names(self): + for rec in self: + names = [] + for inv in rec.invoice_ids: + url = f"/web#id={inv.id}&model=account.move&view_type=form" + name = html_escape(inv.name) + names.append(f'<a href="{url}" target="_blank">{name}</a>') + rec.invoice_names = ', '.join(names) + + + @api.depends('sale_order_ids') + def _compute_so_names(self): + for rec in self: + so_links = [] + for so in rec.sale_order_ids: + url = f"/web#id={so.id}&model=sale.order&view_type=form" + name = html_escape(so.name) + so_links.append(f'<a href="{url}" target="_blank">{name}</a>') + rec.so_names = ', '.join(so_links) if so_links else "-" + + @api.onchange('uang_masuk', 'total_invoice', 'ongkir') + def _onchange_amount_refund(self): + for rec in self: + pengurangan = rec.total_invoice + rec.ongkir + refund = rec.uang_masuk - pengurangan + rec.amount_refund = refund if refund > 0 else 0.0 + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + sale_order_id = self.env.context.get('default_sale_order_id') + if sale_order_id: + so = self.env['sale.order'].browse(sale_order_id) + res['sale_order_ids'] = [(6, 0, [so.id])] + invoice_ids = so.invoice_ids.filtered( + lambda inv: inv.move_type in ['out_invoice', 'out_refund'] and inv.state != 'cancel' + ).ids + res['invoice_ids'] = [(6, 0, invoice_ids)] + res['uang_masuk'] = 0.0 + res['ongkir'] = so.delivery_amt or 0.0 + line_vals = [] + for line in so.order_line: + line_vals.append((0, 0, { + 'product_id': line.product_id.id, + 'quantity': line.product_uom_qty, + 'reason': '', + })) + res['line_ids'] = line_vals + res['refund_type'] = 'uang' if invoice_ids else False + return res + + @api.onchange('invoice_ids') + def _onchange_invoice_ids(self): + if self.invoice_ids: + if self.refund_type not in ['uang', 'barang_kosong']: + self.refund_type = False + + self.total_invoice = sum(self.invoice_ids.mapped('amount_total')) + + def action_ask_approval(self): + for rec in self: + if rec.status == 'draft': + rec.status = 'pengajuan1' + + + def _get_status_label(self, code): + status_dict = dict(self.fields_get(allfields=['status'])['status']['selection']) + return status_dict.get(code, code) + + def action_approve_flow(self): + jakarta_tz = pytz.timezone('Asia/Jakarta') + now = datetime.now(jakarta_tz).replace(tzinfo=None) + + for rec in self: + user_name = self.env.user.name + + if not rec.status or rec.status == 'draft': + rec.status = 'pengajuan1' + + elif rec.status == 'pengajuan1' and self.env.user.id == 19: + rec.status = 'pengajuan2' + rec.approved_by = f"{rec.approved_by}, {user_name}" if rec.approved_by else user_name + rec.date_approved_sales = now + rec.position_sales = 'Sales Manager' + + elif rec.status == 'pengajuan2' and self.env.user.id == 688: + rec.status = 'pengajuan3' + rec.approved_by = f"{rec.approved_by}, {user_name}" if rec.approved_by else user_name + rec.date_approved_ar = now + rec.position_ar = 'AR' + + elif rec.status == 'pengajuan3' and self.env.user.id == 7: + rec.status = 'refund' + rec.approved_by = f"{rec.approved_by}, {user_name}" if rec.approved_by else user_name + rec.date_approved_pimpinan = now + rec.position_pimpinan = 'Pimpinan' + rec.refund_date = fields.Date.context_today(self) + + else: + raise UserError("❌ Hanya bisa diapproved oleh yang bersangkutan.") + + def action_trigger_cancel(self): + is_fat = self.env.user.has_group('indoteknik_custom.group_role_fat') + allowed_user_ids = [19, 688, 7] + for rec in self: + if self.user.id not in allowed_user_ids and not is_fat: + raise UserError("❌ Hanya user yang bersangkutan atau Finance (FAT) yang bisa melakukan penolakan.") + if rec.status not in ['refund', 'reject']: + rec.status = 'reject' + rec.status_payment = 'reject' + + @api.constrains('status', 'reason_reject') + def _check_reason_if_rejected(self): + for rec in self: + if rec.status == 'reject' and not rec.reason_reject: + raise ValidationError("Alasan pembatalan harus diisi ketika status Reject.") + + def action_confirm_refund(self): + is_fat = self.env.user.has_group('indoteknik_custom.group_role_fat') + for rec in self: + if not is_fat: + raise UserError("Hanya Finance yang dapat mengkonfirmasi refund.") + if rec.status_payment == 'pending': + rec.status_payment = 'done' + rec.refund_date = fields.Date.context_today(self) + else: + raise UserError("Refund hanya bisa dikonfirmasi setelah Approval Pimpinan.") + + def _compute_approval_label(self): + for rec in self: + label = 'Approval Done' + if rec.status == 'draft': + label = 'Approval Sales Manager' + elif rec.status == 'pengajuan1': + label = 'Approval AR' + elif rec.status == 'pengajuan2': + label = 'Approval Pimpinan' + elif rec.status == 'pengajuan3': + label = 'Confirm Refund' + rec.approval_button_label = label + + def action_create_journal_refund(self): + is_fat = self.env.user.has_group('indoteknik_custom.group_role_fat') + if not is_fat: + raise UserError("❌ Akses ditolak. Hanya Finance yang dapat membuat journal refund.") + + for refund in self: + current_time = fields.Datetime.now() + has_invoice = any(refund.sale_order_ids.mapped('invoice_ids')) + # Penentuan partner (dari SO atau partner_id langsung) + partner = ( + refund.sale_order_ids[0].partner_id.parent_id or + refund.sale_order_ids[0].partner_id + ) if refund.sale_order_ids else refund.partner_id + + # Ambil label refund type + refund_type_label = dict( + self.fields_get(allfields=['refund_type'])['refund_type']['selection'] + ).get(refund.refund_type, '').replace("Refund ", "").upper() + + + + if not partner: + raise UserError("❌ Partner tidak ditemukan.") + + # Ref format + ref_text = f"REFUND {refund_type_label} {refund.name or ''} {partner.display_name}".upper() + + # Buat Account Move (Journal Entry) + account_move = self.env['account.move'].create({ + 'ref': ref_text, + 'date': current_time, + 'journal_id': 11, + 'refund_id': refund.id, + 'refund_so_ids': [(6, 0, refund.sale_order_ids.ids)], + 'partner_id': partner.id, + }) + + amount = refund.amount_refund + + second_account_id = 450 if has_invoice else 668 + + debit_line = { + 'move_id': account_move.id, + 'account_id': second_account_id, + 'partner_id': partner.id, + 'currency_id': 12, + 'debit': amount, + 'credit': 0.0, + 'name': ref_text, + } + + credit_line = { + 'move_id': account_move.id, + 'account_id': 389, # Intransit BCA + 'partner_id': partner.id, + 'currency_id': 12, + 'debit': 0.0, + 'credit': amount, + 'name': ref_text, + } + + self.env['account.move.line'].create([debit_line, credit_line]) + + return { + 'name': _('Journal Entries'), + 'view_mode': 'form', + 'res_model': 'account.move', + 'type': 'ir.actions.act_window', + 'res_id': account_move.id, + 'target': 'current' + } + + def _compute_journal_refund_move_id(self): + for rec in self: + move = self.env['account.move'].search([ + ('refund_id', '=', rec.id) + ], limit=1) + rec.journal_refund_move_id = move + + def action_open_journal_refund(self): + self.ensure_one() + if self.journal_refund_move_id: + return { + 'name': _('Journal Refund'), + 'view_mode': 'form', + 'res_model': 'account.move', + 'type': 'ir.actions.act_window', + 'res_id': self.journal_refund_move_id.id, + 'target': 'current' + } + + + + +class RefundSaleOrderLine(models.Model): + _name = 'refund.sale.order.line' + _description = 'Refund Sales Order Line' + _inherit = ['mail.thread'] + + refund_id = fields.Many2one('refund.sale.order', string='Refund Ref') + product_id = fields.Many2one('product.product', string='Product') + quantity = fields.Float(string='Qty') + reason = fields.Char(string='Reason') diff --git a/indoteknik_custom/models/requisition.py b/indoteknik_custom/models/requisition.py index 74236850..bcdafb12 100644 --- a/indoteknik_custom/models/requisition.py +++ b/indoteknik_custom/models/requisition.py @@ -48,8 +48,8 @@ class Requisition(models.Model): is_po = fields.Boolean(string='Is PO') requisition_match = fields.One2many('requisition.purchase.match', 'requisition_id', string='Matches', auto_join=True) sale_order_id = fields.Many2one('sale.order', string='SO', help='harus diisi nomor SO yang ingin digenerate') - sales_approve = fields.Boolean(string='Sales Approve', tracking=3, copy=False) - merchandise_approve = fields.Boolean(string='Merchandise Approve', tracking=3, copy=False) + sales_approve = fields.Boolean(string='Approval Status', tracking=3, copy=False) + merchandise_approve = fields.Boolean(string='Approval Status', tracking=3, copy=False) def generate_requisition_from_so(self): state = ['done', 'sale'] @@ -82,20 +82,16 @@ class Requisition(models.Model): state = ['done', 'sale'] if self.sale_order_id.state in state: raise UserError('SO sudah Confirm, akan berakibat double Purchase melalui PJ') - if self.env.user.id not in [377, 19, 28]: - raise UserError('Hanya Vita dan Darren Yang Bisa Approve') - if self.env.user.id == 377 or self.env.user.id == 28: + if self.env.user.id not in [21, 19, 28]: + raise UserError('Hanya Rafly dan Darren Yang Bisa Approve') + if self.env.user.id == 19 or self.env.user.id == 28: self.sales_approve = True - elif self.env.user.id == 19 or self.env.user.id == 28: - if not self.sales_approve: - raise UserError('Vita Belum Approve') + elif self.env.user.id == 21 or self.env.user.id == 28: self.merchandise_approve = True def create_po_from_requisition(self): - if not self.sales_approve: - raise UserError('Harus Di Approve oleh Vita') - if not self.merchandise_approve: - raise UserError('Harus Di Approve oleh Darren') + if not self.sales_approve and not self.merchandise_approve: + raise UserError('Harus Di Approve oleh Darren atau Rafly') if not self.requisition_lines: raise UserError('Tidak ada Lines, belum bisa create PO') if self.is_po: diff --git a/indoteknik_custom/models/res_partner.py b/indoteknik_custom/models/res_partner.py index 191a44c9..236df16f 100644 --- a/indoteknik_custom/models/res_partner.py +++ b/indoteknik_custom/models/res_partner.py @@ -3,6 +3,9 @@ from odoo.exceptions import UserError, ValidationError from datetime import datetime from odoo.http import request import re +import requests +import logging +_logger = logging.getLogger(__name__) class GroupPartner(models.Model): _name = 'group.partner' @@ -145,7 +148,8 @@ class ResPartner(models.Model): date_payment_terms_purchase = fields.Datetime(string='Date Update Payment Terms') longtitude = fields.Char(string='Longtitude') latitude = fields.Char(string='Latitude') - address_map = fields.Char(string='Address Map') + map_view = fields.Char(string='Map') + address_map = fields.Char(string='Address Map', help='Alamat ini diisi otomatis berdasarkan koordinat pin pada peta. Silakan koreksi dan ubah jika terdapat ketidaksesuaian', tracking=3) company_type = fields.Selection(string='Company Type', selection=[('person', 'Individual'), ('company', 'Company')], compute='_compute_company_type', inverse='_write_company_type', tracking=3) @@ -160,6 +164,14 @@ class ResPartner(models.Model): "Set its value to 0.00 to disable " "this feature", tracking=3) telegram_id = fields.Char(string="Telegram") + avg_aging= fields.Float(string='Average Aging') + payment_difficulty = fields.Selection([('bermasalah', 'Bermasalah'),('sulit', 'Sulit'),('agak_sulit', 'Agak Sulit'),('normal', 'Normal')], string='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.model def _default_payment_term(self): @@ -184,6 +196,14 @@ class ResPartner(models.Model): def write(self, vals): res = super(ResPartner, self).write(vals) + + for rec in self: + if 'latitude' in vals or 'longtitude' in vals: + rec._update_address_from_coords() + + # Sinkronisasi payment_difficulty ke semua anak jika partner ini adalah parent + if not rec.parent_id and 'payment_difficulty' in vals: + rec.child_ids.write({'payment_difficulty': vals['payment_difficulty']}) # # # if 'property_payment_term_id' in vals: # # if not self.env.user.is_accounting and vals['property_payment_term_id'] != 26: @@ -195,6 +215,16 @@ class ResPartner(models.Model): # # raise UserError('You name it') # return res + + @api.model + def create(self, vals): + records = super().create(vals) + for rec in records: + if vals.get('latitude') and vals.get('longtitude'): + rec._update_address_from_coords() + if rec.parent_id and not vals.get('payment_difficulty'): + rec.payment_difficulty = rec.parent_id.payment_difficulty + return records @api.constrains('name') def _check_duplicate_name(self): @@ -235,182 +265,182 @@ class ResPartner(models.Model): raise ValidationError("Digit NPWP yang dimasukkan tidak sesuai. Pastikan NPWP memiliki 15 digit dengan format tertentu (99.999.999.9-999.999) atau 16 digit tanpa tanda hubung.") - def write(self, vals): - # Fungsi rekursif untuk meng-update semua child, termasuk child dari child - def update_children_recursively(partner, vals_for_child): - # Lakukan update pada partner saat ini hanya dengan field yang diizinkan - partner.write(vals_for_child) - - # Untuk setiap child dari partner ini, update juga child-nya - for child in partner.child_ids: - update_children_recursively(child, vals_for_child) - - # Jika self tidak memiliki parent_id, artinya self adalah parent - if not self.parent_id: - # Ambil semua child dari parent ini - children = self.child_ids - - # Perbarui vals dengan nilai dari parent jika tidak ada dalam vals - vals['customer_type'] = vals.get('customer_type', self.customer_type) - vals['nama_wajib_pajak'] = vals.get('nama_wajib_pajak', self.nama_wajib_pajak) - vals['npwp'] = vals.get('npwp', self.npwp) - vals['sppkp'] = vals.get('sppkp', self.sppkp) - vals['alamat_lengkap_text'] = vals.get('alamat_lengkap_text', self.alamat_lengkap_text) - vals['industry_id'] = vals.get('industry_id', self.industry_id.id if self.industry_id else None) - vals['company_type_id'] = vals.get('company_type_id', self.company_type_id.id if self.company_type_id else None) - - # Referensi - vals['supplier_ids'] = vals.get('supplier_ids', self.supplier_ids) - - # informasi perusahaan - vals['name_tempo'] = vals.get('name_tempo', self.name_tempo) - vals['industry_id_tempo'] = vals.get('industry_id_tempo', self.industry_id_tempo) - vals['street_tempo'] = vals.get('street_tempo', self.street_tempo) - vals['state_id_tempo'] = vals.get('state_id_tempo', self.state_id_tempo) - vals['city_id_tempo'] = vals.get('city_id_tempo', self.city_id_tempo) - vals['zip_tempo'] = vals.get('zip_tempo', self.zip_tempo) - vals['bank_name_tempo'] = vals.get('bank_name_tempo', self.bank_name_tempo) - vals['account_name_tempo'] = vals.get('account_name_tempo', self.account_name_tempo) - vals['account_number_tempo'] = vals.get('account_number_tempo', self.account_number_tempo) - vals['website_tempo'] = vals.get('website_tempo', self.website_tempo) - vals['portal'] = vals.get('portal', self.portal) - vals['estimasi_tempo'] = vals.get('estimasi_tempo', self.estimasi_tempo) - vals['tempo_duration'] = vals.get('tempo_duration', self.tempo_duration) - vals['tempo_limit'] = vals.get('tempo_limit', self.tempo_limit) - vals['category_produk_ids'] = vals.get('category_produk_ids', self.category_produk_ids) - - # Kontak Perusahaan - vals['direktur_name'] = vals.get('direktur_name', self.direktur_name) - vals['direktur_mobile'] = vals.get('direktur_mobile', self.direktur_mobile) - vals['direktur_email'] = vals.get('direktur_email', self.direktur_email) - vals['purchasing_name'] = vals.get('purchasing_name', self.purchasing_name) - vals['purchasing_mobile'] = vals.get('purchasing_mobile', self.purchasing_mobile) - vals['purchasing_email'] = vals.get('purchasing_email', self.purchasing_email) - vals['finance_name'] = vals.get('finance_name', self.finance_name) - vals['finance_mobile'] = vals.get('finance_mobile', self.finance_mobile) - vals['finance_email'] = vals.get('finance_email', self.finance_email) - - # Pengiriman - vals['pic_name'] = vals.get('pic_name', self.pic_name) - vals['pic_mobile'] = vals.get('pic_mobile', self.pic_mobile) - vals['street_pengiriman'] = vals.get('street_pengiriman', self.street_pengiriman) - vals['state_id_pengiriman'] = vals.get('state_id_pengiriman', self.state_id_pengiriman) - vals['city_id_pengiriman'] = vals.get('city_id_pengiriman', self.city_id_pengiriman) - vals['district_id_pengiriman'] = vals.get('district_id_pengiriman', self.district_id_pengiriman) - vals['subDistrict_id_pengiriman'] = vals.get('subDistrict_id_pengiriman', self.subDistrict_id_pengiriman) - vals['zip_pengiriman'] = vals.get('zip_pengiriman', self.zip_pengiriman) - vals['invoice_pic'] = vals.get('invoice_pic', self.invoice_pic) - vals['invoice_pic_mobile'] = vals.get('invoice_pic_mobile', self.invoice_pic_mobile) - vals['street_invoice'] = vals.get('street_invoice', self.street_invoice) - vals['state_id_invoice'] = vals.get('state_id_invoice', self.state_id_invoice) - vals['city_id_invoice'] = vals.get('city_id_invoice', self.city_id_invoice) - vals['district_id_invoice'] = vals.get('district_id_invoice', self.district_id_invoice) - vals['subDistrict_id_invoice'] = vals.get('subDistrict_id_invoice', self.subDistrict_id_invoice) - vals['zip_invoice'] = vals.get('zip_invoice', self.zip_invoice) - vals['tukar_invoice'] = vals.get('tukar_invoice', self.tukar_invoice) - vals['jadwal_bayar'] = vals.get('jadwal_bayar', self.jadwal_bayar) - vals['dokumen_prosedur'] = vals.get('dokumen_prosedur', self.dokumen_prosedur) - vals['dokumen_pengiriman'] = vals.get('dokumen_pengiriman', self.dokumen_pengiriman) - vals['dokumen_pengiriman_input'] = vals.get('dokumen_pengiriman_input', self.dokumen_pengiriman_input) - vals['dokumen_invoice'] = vals.get('dokumen_invoice', self.dokumen_invoice) - - # Dokumen - vals['dokumen_npwp'] = vals.get('dokumen_npwp', self.dokumen_npwp) - vals['dokumen_sppkp'] = vals.get('dokumen_sppkp', self.dokumen_sppkp) - vals['dokumen_nib'] = vals.get('dokumen_nib', self.dokumen_nib) - vals['dokumen_siup'] = vals.get('dokumen_siup', self.dokumen_siup) - vals['dokumen_tdp'] = vals.get('dokumen_tdp', self.dokumen_tdp) - vals['dokumen_skdp'] = vals.get('dokumen_skdp', self.dokumen_skdp) - vals['dokumen_skt'] = vals.get('dokumen_skt', self.dokumen_skt) - vals['dokumen_akta_perubahan'] = vals.get('dokumen_akta_perubahan', self.dokumen_akta_perubahan) - vals['dokumen_ktp_dirut'] = vals.get('dokumen_ktp_dirut', self.dokumen_ktp_dirut) - vals['dokumen_akta_pendirian'] = vals.get('dokumen_akta_pendirian', self.dokumen_akta_pendirian) - vals['dokumen_laporan_keuangan'] = vals.get('dokumen_laporan_keuangan', self.dokumen_laporan_keuangan) - vals['dokumen_foto_kantor'] = vals.get('dokumen_foto_kantor', self.dokumen_foto_kantor) - vals['dokumen_tempat_bekerja'] = vals.get('dokumen_tempat_bekerja', self.dokumen_tempat_bekerja) - - # Simpan hanya field yang perlu di-update pada child - vals_for_child = { - 'customer_type': vals.get('customer_type'), - 'nama_wajib_pajak': vals.get('nama_wajib_pajak'), - 'npwp': vals.get('npwp'), - 'sppkp': vals.get('sppkp'), - 'alamat_lengkap_text': vals.get('alamat_lengkap_text'), - 'industry_id': vals.get('industry_id'), - 'company_type_id': vals.get('company_type_id'), - 'supplier_ids': vals.get('supplier_ids'), - 'name_tempo': vals.get('name_tempo'), - 'industry_id_tempo': vals.get('industry_id_tempo'), - 'street_tempo': vals.get('street_tempo'), - 'state_id_tempo': vals.get('state_id_tempo'), - 'city_id_tempo': vals.get('city_id_tempo'), - 'zip_tempo': vals.get('zip_tempo'), - 'bank_name_tempo': vals.get('bank_name_tempo'), - 'account_name_tempo': vals.get('account_name_tempo'), - 'account_number_tempo': vals.get('account_number_tempo'), - 'website_tempo': vals.get('website_tempo'), - 'portal': vals.get('portal'), - 'estimasi_tempo': vals.get('estimasi_tempo'), - 'tempo_duration': vals.get('tempo_duration'), - 'tempo_limit': vals.get('tempo_limit'), - 'category_produk_ids': vals.get('category_produk_ids'), - 'direktur_name': vals.get('direktur_name'), - 'direktur_mobile': vals.get('direktur_mobile'), - 'direktur_email': vals.get('direktur_email'), - 'purchasing_name': vals.get('purchasing_name'), - 'purchasing_mobile': vals.get('purchasing_mobile'), - 'purchasing_email': vals.get('purchasing_email'), - 'finance_name': vals.get('finance_name'), - 'finance_mobile': vals.get('finance_mobile'), - 'finance_email': vals.get('finance_email'), - 'pic_name': vals.get('pic_name'), - 'pic_mobile': vals.get('pic_mobile'), - 'street_pengiriman': vals.get('street_pengiriman'), - 'state_id_pengiriman': vals.get('state_id_pengiriman'), - 'city_id_pengiriman': vals.get('city_id_pengiriman'), - 'district_id_pengiriman': vals.get('district_id_pengiriman'), - 'subDistrict_id_pengiriman': vals.get('subDistrict_id_pengiriman'), - 'zip_pengiriman': vals.get('zip_pengiriman'), - 'invoice_pic': vals.get('invoice_pic'), - 'invoice_pic_mobile': vals.get('invoice_pic_mobile'), - 'street_invoice': vals.get('street_invoice'), - 'state_id_invoice': vals.get('state_id_invoice'), - 'city_id_invoice': vals.get('city_id_invoice'), - 'district_id_invoice': vals.get('district_id_invoice'), - 'subDistrict_id_invoice': vals.get('subDistrict_id_invoice'), - 'zip_invoice': vals.get('zip_invoice'), - 'tukar_invoice': vals.get('tukar_invoice'), - 'jadwal_bayar': vals.get('jadwal_bayar'), - 'dokumen_prosedur': vals.get('dokumen_prosedur'), - 'dokumen_pengiriman': vals.get('dokumen_pengiriman'), - 'dokumen_pengiriman_input': vals.get('dokumen_pengiriman_input'), - 'dokumen_invoice': vals.get('dokumen_invoice'), - 'dokumen_npwp': vals.get('dokumen_npwp'), - 'dokumen_sppkp': vals.get('dokumen_sppkp'), - 'dokumen_nib': vals.get('dokumen_nib'), - 'dokumen_siup': vals.get('dokumen_siup'), - 'dokumen_tdp': vals.get('dokumen_tdp'), - 'dokumen_skdp': vals.get('dokumen_skdp'), - 'dokumen_skt': vals.get('dokumen_skt'), - 'dokumen_akta_perubahan': vals.get('dokumen_akta_perubahan'), - 'dokumen_ktp_dirut': vals.get('dokumen_ktp_dirut'), - 'dokumen_akta_pendirian': vals.get('dokumen_akta_pendirian'), - 'dokumen_laporan_keuangan': vals.get('dokumen_laporan_keuangan'), - 'dokumen_foto_kantor': vals.get('dokumen_foto_kantor'), - 'dokumen_tempat_bekerja': vals.get('dokumen_tempat_bekerja'), - - # internal_notes - 'comment': vals.get('comment') - } - - # Lakukan update pada semua child secara rekursif - for child in children: - update_children_recursively(child, vals_for_child) - - # Lakukan write untuk parent dengan vals asli - res = super(ResPartner, self).write(vals) - - return res + # def write(self, vals): + # # Fungsi rekursif untuk meng-update semua child, termasuk child dari child + # def update_children_recursively(partner, vals_for_child): + # # Lakukan update pada partner saat ini hanya dengan field yang diizinkan + # partner.write(vals_for_child) + # + # # Untuk setiap child dari partner ini, update juga child-nya + # for child in partner.child_ids: + # update_children_recursively(child, vals_for_child) + # + # # Jika self tidak memiliki parent_id, artinya self adalah parent + # if not self.parent_id: + # # Ambil semua child dari parent ini + # children = self.child_ids + # + # # Perbarui vals dengan nilai dari parent jika tidak ada dalam vals + # vals['customer_type'] = vals.get('customer_type', self.customer_type) + # vals['nama_wajib_pajak'] = vals.get('nama_wajib_pajak', self.nama_wajib_pajak) + # vals['npwp'] = vals.get('npwp', self.npwp) + # vals['sppkp'] = vals.get('sppkp', self.sppkp) + # vals['alamat_lengkap_text'] = vals.get('alamat_lengkap_text', self.alamat_lengkap_text) + # vals['industry_id'] = vals.get('industry_id', self.industry_id.id if self.industry_id else None) + # vals['company_type_id'] = vals.get('company_type_id', self.company_type_id.id if self.company_type_id else None) + # + # # Referensi + # vals['supplier_ids'] = vals.get('supplier_ids', self.supplier_ids) + # + # # informasi perusahaan + # vals['name_tempo'] = vals.get('name_tempo', self.name_tempo) + # vals['industry_id_tempo'] = vals.get('industry_id_tempo', self.industry_id_tempo) + # vals['street_tempo'] = vals.get('street_tempo', self.street_tempo) + # vals['state_id_tempo'] = vals.get('state_id_tempo', self.state_id_tempo) + # vals['city_id_tempo'] = vals.get('city_id_tempo', self.city_id_tempo) + # vals['zip_tempo'] = vals.get('zip_tempo', self.zip_tempo) + # vals['bank_name_tempo'] = vals.get('bank_name_tempo', self.bank_name_tempo) + # vals['account_name_tempo'] = vals.get('account_name_tempo', self.account_name_tempo) + # vals['account_number_tempo'] = vals.get('account_number_tempo', self.account_number_tempo) + # vals['website_tempo'] = vals.get('website_tempo', self.website_tempo) + # vals['portal'] = vals.get('portal', self.portal) + # vals['estimasi_tempo'] = vals.get('estimasi_tempo', self.estimasi_tempo) + # vals['tempo_duration'] = vals.get('tempo_duration', self.tempo_duration) + # vals['tempo_limit'] = vals.get('tempo_limit', self.tempo_limit) + # vals['category_produk_ids'] = vals.get('category_produk_ids', self.category_produk_ids) + # + # # Kontak Perusahaan + # vals['direktur_name'] = vals.get('direktur_name', self.direktur_name) + # vals['direktur_mobile'] = vals.get('direktur_mobile', self.direktur_mobile) + # vals['direktur_email'] = vals.get('direktur_email', self.direktur_email) + # vals['purchasing_name'] = vals.get('purchasing_name', self.purchasing_name) + # vals['purchasing_mobile'] = vals.get('purchasing_mobile', self.purchasing_mobile) + # vals['purchasing_email'] = vals.get('purchasing_email', self.purchasing_email) + # vals['finance_name'] = vals.get('finance_name', self.finance_name) + # vals['finance_mobile'] = vals.get('finance_mobile', self.finance_mobile) + # vals['finance_email'] = vals.get('finance_email', self.finance_email) + # + # # Pengiriman + # vals['pic_name'] = vals.get('pic_name', self.pic_name) + # vals['pic_mobile'] = vals.get('pic_mobile', self.pic_mobile) + # vals['street_pengiriman'] = vals.get('street_pengiriman', self.street_pengiriman) + # vals['state_id_pengiriman'] = vals.get('state_id_pengiriman', self.state_id_pengiriman) + # vals['city_id_pengiriman'] = vals.get('city_id_pengiriman', self.city_id_pengiriman) + # vals['district_id_pengiriman'] = vals.get('district_id_pengiriman', self.district_id_pengiriman) + # vals['subDistrict_id_pengiriman'] = vals.get('subDistrict_id_pengiriman', self.subDistrict_id_pengiriman) + # vals['zip_pengiriman'] = vals.get('zip_pengiriman', self.zip_pengiriman) + # vals['invoice_pic'] = vals.get('invoice_pic', self.invoice_pic) + # vals['invoice_pic_mobile'] = vals.get('invoice_pic_mobile', self.invoice_pic_mobile) + # vals['street_invoice'] = vals.get('street_invoice', self.street_invoice) + # vals['state_id_invoice'] = vals.get('state_id_invoice', self.state_id_invoice) + # vals['city_id_invoice'] = vals.get('city_id_invoice', self.city_id_invoice) + # vals['district_id_invoice'] = vals.get('district_id_invoice', self.district_id_invoice) + # vals['subDistrict_id_invoice'] = vals.get('subDistrict_id_invoice', self.subDistrict_id_invoice) + # vals['zip_invoice'] = vals.get('zip_invoice', self.zip_invoice) + # vals['tukar_invoice'] = vals.get('tukar_invoice', self.tukar_invoice) + # vals['jadwal_bayar'] = vals.get('jadwal_bayar', self.jadwal_bayar) + # vals['dokumen_prosedur'] = vals.get('dokumen_prosedur', self.dokumen_prosedur) + # vals['dokumen_pengiriman'] = vals.get('dokumen_pengiriman', self.dokumen_pengiriman) + # vals['dokumen_pengiriman_input'] = vals.get('dokumen_pengiriman_input', self.dokumen_pengiriman_input) + # vals['dokumen_invoice'] = vals.get('dokumen_invoice', self.dokumen_invoice) + # + # # Dokumen + # vals['dokumen_npwp'] = vals.get('dokumen_npwp', self.dokumen_npwp) + # vals['dokumen_sppkp'] = vals.get('dokumen_sppkp', self.dokumen_sppkp) + # vals['dokumen_nib'] = vals.get('dokumen_nib', self.dokumen_nib) + # vals['dokumen_siup'] = vals.get('dokumen_siup', self.dokumen_siup) + # vals['dokumen_tdp'] = vals.get('dokumen_tdp', self.dokumen_tdp) + # vals['dokumen_skdp'] = vals.get('dokumen_skdp', self.dokumen_skdp) + # vals['dokumen_skt'] = vals.get('dokumen_skt', self.dokumen_skt) + # vals['dokumen_akta_perubahan'] = vals.get('dokumen_akta_perubahan', self.dokumen_akta_perubahan) + # vals['dokumen_ktp_dirut'] = vals.get('dokumen_ktp_dirut', self.dokumen_ktp_dirut) + # vals['dokumen_akta_pendirian'] = vals.get('dokumen_akta_pendirian', self.dokumen_akta_pendirian) + # vals['dokumen_laporan_keuangan'] = vals.get('dokumen_laporan_keuangan', self.dokumen_laporan_keuangan) + # vals['dokumen_foto_kantor'] = vals.get('dokumen_foto_kantor', self.dokumen_foto_kantor) + # vals['dokumen_tempat_bekerja'] = vals.get('dokumen_tempat_bekerja', self.dokumen_tempat_bekerja) + # + # # Simpan hanya field yang perlu di-update pada child + # vals_for_child = { + # 'customer_type': vals.get('customer_type'), + # 'nama_wajib_pajak': vals.get('nama_wajib_pajak'), + # 'npwp': vals.get('npwp'), + # 'sppkp': vals.get('sppkp'), + # 'alamat_lengkap_text': vals.get('alamat_lengkap_text'), + # 'industry_id': vals.get('industry_id'), + # 'company_type_id': vals.get('company_type_id'), + # 'supplier_ids': vals.get('supplier_ids'), + # 'name_tempo': vals.get('name_tempo'), + # 'industry_id_tempo': vals.get('industry_id_tempo'), + # 'street_tempo': vals.get('street_tempo'), + # 'state_id_tempo': vals.get('state_id_tempo'), + # 'city_id_tempo': vals.get('city_id_tempo'), + # 'zip_tempo': vals.get('zip_tempo'), + # 'bank_name_tempo': vals.get('bank_name_tempo'), + # 'account_name_tempo': vals.get('account_name_tempo'), + # 'account_number_tempo': vals.get('account_number_tempo'), + # 'website_tempo': vals.get('website_tempo'), + # 'portal': vals.get('portal'), + # 'estimasi_tempo': vals.get('estimasi_tempo'), + # 'tempo_duration': vals.get('tempo_duration'), + # 'tempo_limit': vals.get('tempo_limit'), + # 'category_produk_ids': vals.get('category_produk_ids'), + # 'direktur_name': vals.get('direktur_name'), + # 'direktur_mobile': vals.get('direktur_mobile'), + # 'direktur_email': vals.get('direktur_email'), + # 'purchasing_name': vals.get('purchasing_name'), + # 'purchasing_mobile': vals.get('purchasing_mobile'), + # 'purchasing_email': vals.get('purchasing_email'), + # 'finance_name': vals.get('finance_name'), + # 'finance_mobile': vals.get('finance_mobile'), + # 'finance_email': vals.get('finance_email'), + # 'pic_name': vals.get('pic_name'), + # 'pic_mobile': vals.get('pic_mobile'), + # 'street_pengiriman': vals.get('street_pengiriman'), + # 'state_id_pengiriman': vals.get('state_id_pengiriman'), + # 'city_id_pengiriman': vals.get('city_id_pengiriman'), + # 'district_id_pengiriman': vals.get('district_id_pengiriman'), + # 'subDistrict_id_pengiriman': vals.get('subDistrict_id_pengiriman'), + # 'zip_pengiriman': vals.get('zip_pengiriman'), + # 'invoice_pic': vals.get('invoice_pic'), + # 'invoice_pic_mobile': vals.get('invoice_pic_mobile'), + # 'street_invoice': vals.get('street_invoice'), + # 'state_id_invoice': vals.get('state_id_invoice'), + # 'city_id_invoice': vals.get('city_id_invoice'), + # 'district_id_invoice': vals.get('district_id_invoice'), + # 'subDistrict_id_invoice': vals.get('subDistrict_id_invoice'), + # 'zip_invoice': vals.get('zip_invoice'), + # 'tukar_invoice': vals.get('tukar_invoice'), + # 'jadwal_bayar': vals.get('jadwal_bayar'), + # 'dokumen_prosedur': vals.get('dokumen_prosedur'), + # 'dokumen_pengiriman': vals.get('dokumen_pengiriman'), + # 'dokumen_pengiriman_input': vals.get('dokumen_pengiriman_input'), + # 'dokumen_invoice': vals.get('dokumen_invoice'), + # 'dokumen_npwp': vals.get('dokumen_npwp'), + # 'dokumen_sppkp': vals.get('dokumen_sppkp'), + # 'dokumen_nib': vals.get('dokumen_nib'), + # 'dokumen_siup': vals.get('dokumen_siup'), + # 'dokumen_tdp': vals.get('dokumen_tdp'), + # 'dokumen_skdp': vals.get('dokumen_skdp'), + # 'dokumen_skt': vals.get('dokumen_skt'), + # 'dokumen_akta_perubahan': vals.get('dokumen_akta_perubahan'), + # 'dokumen_ktp_dirut': vals.get('dokumen_ktp_dirut'), + # 'dokumen_akta_pendirian': vals.get('dokumen_akta_pendirian'), + # 'dokumen_laporan_keuangan': vals.get('dokumen_laporan_keuangan'), + # 'dokumen_foto_kantor': vals.get('dokumen_foto_kantor'), + # 'dokumen_tempat_bekerja': vals.get('dokumen_tempat_bekerja'), + # + # # internal_notes + # 'comment': vals.get('comment') + # } + # + # # Lakukan update pada semua child secara rekursif + # for child in children: + # update_children_recursively(child, vals_for_child) + # + # # Lakukan write untuk parent dengan vals asli + # res = super(ResPartner, self).write(vals) + # + # return res # if self.company_type == 'person' and not partner.parent_id: # if self.parent_id: @@ -521,4 +551,144 @@ class ResPartner(models.Model): @api.onchange('name') def _onchange_name(self): if self.company_type == 'person': - self.nama_wajib_pajak = self.name
\ No newline at end of file + self.nama_wajib_pajak = self.name + + def action_open_full_form(self): + return { + 'type': 'ir.actions.act_window', + 'name': 'Partner', + 'res_model': 'res.partner', + 'res_id': self.id, + 'view_mode': 'form', + 'target': 'current', + } + + def geocode_address(self): + for rec in self: + # Daftar field penting + required_fields = { + 'Alamat Jalan (street)': rec.street, + 'Kelurahan': rec.kelurahan_id.name if rec.kelurahan_id else '', + 'Kecamatan': rec.kecamatan_id.name if rec.kecamatan_id else '', + 'Kota': rec.kota_id.name if rec.kota_id else '', + 'Kode Pos': rec.zip, + 'Provinsi': rec.state_id.name if rec.state_id else '', + } + + # Cek jika ada yang kosong + missing = [label for label, val in required_fields.items() if not val] + if missing: + raise UserError( + "Alamat tidak lengkap. Mohon lengkapi field berikut:\n- " + "\n- ".join(missing) + ) + + # Susun alamat lengkap + address = ', '.join([ + required_fields['Alamat Jalan (street)'], + required_fields['Kelurahan'], + required_fields['Kecamatan'], + required_fields['Kota'], + required_fields['Kode Pos'], + required_fields['Provinsi'], + ]) + + # Ambil API Key + api_key = "AIzaSyB7bG9aSNAJnSrj0Z7f1abFsqKVoiJfsPE" + # api_key = self.env['ir.config_parameter'].sudo().get_param('google.maps.api_key') + # if not api_key: + # raise UserError("API Key Google Maps belum dikonfigurasi. Silakan isi melalui Settings.") + + # Request ke Google Maps + url = f'https://maps.googleapis.com/maps/api/geocode/json?address={address}&key={api_key}' + response = requests.get(url) + + if response.ok: + result = response.json() + if result.get('results'): + location = result['results'][0]['geometry']['location'] + formatted_address = result['results'][0].get('formatted_address', '') + + rec.latitude = location['lat'] + rec.longtitude = location['lng'] + rec.address_map = formatted_address # ✅ Simpan alamat lengkap + else: + raise UserError("Tidak ditemukan hasil geocode untuk alamat tersebut.") + else: + raise UserError("Permintaan ke Google Maps gagal. Periksa koneksi internet atau API Key.") + + def _update_address_from_coords(self): + for rec in self: + if rec.latitude and rec.longtitude: + try: + components, formatted, parsed = rec._reverse_geocode(rec.latitude, rec.longtitude) + if not parsed: + continue + + updates = { + 'street': parsed.get('road') or '', + 'zip': parsed.get('postcode') or '', + 'address_map': formatted or '', + } + + state = self.env['res.country.state'].search([('name', 'ilike', parsed.get('state'))], limit=1) + if state: + updates['state_id'] = state.id + + kota = self.env['vit.kota'].search([('name', 'ilike', parsed.get('city'))], limit=1) + if kota: + updates['kota_id'] = kota.id + + kec = self.env['vit.kecamatan'].search([('name', 'ilike', parsed.get('district'))], limit=1) + if kec: + updates['kecamatan_id'] = kec.id + + kel = self.env['vit.kelurahan'].search([('name', 'ilike', parsed.get('suburb'))], limit=1) + if kel: + updates['kelurahan_id'] = kel.id + + rec.update(updates) + + except Exception as e: + raise UserError(f"Gagal update alamat dari koordinat: {str(e)}") + + + def _reverse_geocode(self, lat, lng): + api_key = "AIzaSyB7bG9aSNAJnSrj0Z7f1abFsqKVoiJfsPE" + # api_key = self.env['ir.config_parameter'].sudo().get_param('google.maps.api_key') + # if not api_key: + # raise UserError("API Key Google Maps belum dikonfigurasi.") + + url = f'https://maps.googleapis.com/maps/api/geocode/json?latlng={lat},{lng}&key={api_key}' + response = requests.get(url) + if response.ok: + result = response.json() + if result.get('results'): + components = result['results'][0]['address_components'] + formatted = result['results'][0]['formatted_address'] + return components, formatted, self._parse_google_address(components) + return {}, '', {} + + def _parse_google_address(self, components): + def get(types): + for comp in components: + if types in comp['types']: + return comp['long_name'] + return '' + + street_number = get('street_number') + route = get('route') + neighborhood = get('neighborhood') # Bisa jadi nama RW + subpremise = get('subpremise') # Bisa jadi no kamar/ruko + + # Gabungkan informasi jalan + road = " ".join(filter(None, [route, street_number, subpremise, neighborhood])) + + return { + 'road': road.strip(), + 'postcode': get('postal_code'), + 'state': get('administrative_area_level_1'), + 'city': get('administrative_area_level_2') or get('locality'), + 'district': get('administrative_area_level_3'), + 'suburb': get('administrative_area_level_4'), + 'formatted': get('formatted_address'), + } diff --git a/indoteknik_custom/models/sale_order.py b/indoteknik_custom/models/sale_order.py index f89dfb10..7be0e8ff 100755 --- a/indoteknik_custom/models/sale_order.py +++ b/indoteknik_custom/models/sale_order.py @@ -2,11 +2,13 @@ from re import search from odoo import fields, models, api, _ from odoo.exceptions import UserError, ValidationError -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone, time import logging, random, string, requests, math, json, re, qrcode, base64 import pytz from io import BytesIO from collections import defaultdict +import pytz +from lxml import etree _logger = logging.getLogger(__name__) @@ -65,6 +67,7 @@ class ShippingOption(models.Model): price = fields.Float(string="Price", required=True) provider = fields.Char(string="Provider") etd = fields.Char(string="Estimated Delivery Time") + courier_service_code = fields.Char(string="Courier Service Code") sale_order_id = fields.Many2one('sale.order', string="Sale Order", ondelete="cascade") @@ -72,6 +75,7 @@ class SaleOrderLine(models.Model): _inherit = 'sale.order.line' def unlink(self): + lines_to_reject = [] for line in self: if line.order_id: @@ -121,6 +125,7 @@ class SaleOrderLine(models.Model): class SaleOrder(models.Model): _inherit = "sale.order" + ccm_id = fields.Many2one('tukar.guling', string='Doc. CCM', readonly=True, compute='_has_ccm', copy=False) ongkir_ke_xpdc = fields.Float(string='Ongkir ke Ekspedisi', help='Biaya ongkir ekspedisi', copy=False, index=True, tracking=3) @@ -148,8 +153,8 @@ class SaleOrder(models.Model): help="Total Margin in Sales Order Header") total_percent_margin = fields.Float('Total Percent Margin', compute='_compute_total_percent_margin', help="Total % Margin in Sales Order Header") - total_margin_excl_third_party = fields.Float('Before Margin', help="Before Margin in Sales Order Header", - compute='_compute_total_margin_excl_third_party') + total_margin_excl_third_party = fields.Float('Before Margin', help="Before Margin in Sales Order Header") + approval_status = fields.Selection([ ('pengajuan1', 'Approval Manager'), ('pengajuan2', 'Approval Pimpinan'), @@ -324,8 +329,13 @@ class SaleOrder(models.Model): string="Attachment Bukti Cancel", readonly=False, ) nomor_so_pengganti = fields.Char(string='Nomor SO Pengganti', copy=False, tracking=3) - shipping_option_id = fields.Many2one("shipping.option", string="Selected Shipping Option", - domain="['|', ('sale_order_id', '=', False), ('sale_order_id', '=', id)]") + + shipping_option_id = fields.Many2one("shipping.option", string="Selected Shipping Option", domain="['|', ('sale_order_id', '=', False), ('sale_order_id', '=', id)]") + select_shipping_option = fields.Selection([ + ('biteship', 'Biteship'), + ('custom', 'Custom'), + ], string='Shipping Option', help="Select shipping option for delivery", tracking=True, default='custom') + hold_outgoing = fields.Boolean('Hold Outgoing SO', tracking=3) state_ask_cancel = fields.Selection([ ('hold', 'Hold'), @@ -340,16 +350,364 @@ class SaleOrder(models.Model): date_unhold = fields.Datetime(string='Date Unhold', tracking=True, readonly=True, help='Waktu ketika SO di Unhold' ) - def _compute_total_margin_excl_third_party(self): + et_products = fields.Datetime(string='ET Products', compute='_compute_et_products', help="Leadtime produk berdasarkan SLA vendor, tanpa logistik.") + + eta_date_reserved = fields.Datetime( + string="Date Reserved", + compute="_compute_eta_date_reserved", + help="Tanggal pertama kali barang berhasil di-reservasi pada DO (BU/PICK/) yang berstatus Siap Dikirim." + ) + refund_ids = fields.Many2many('refund.sale.order', compute='_compute_refund_ids', string='Refunds') + has_refund = fields.Boolean(string='Has Refund', compute='_compute_has_refund') + refund_count = fields.Integer(string='Refund Count', compute='_compute_refund_count') + advance_payment_move_id = fields.Many2one( + 'account.move', + compute='_compute_advance_payment_move', + string='Advance Payment Move', + ) + advance_payment_move_ids = fields.Many2many( + 'account.move', + compute='_compute_advance_payment_moves', + string='All Advance Payment Moves', + ) + + advance_payment_move_count = fields.Integer( + string='Jumlah Jurnal Uang Muka', + compute='_compute_advance_payment_moves', + store=False + ) + + def _has_ccm(self): + if self.id: + self.ccm_id = self.env['tukar.guling'].search([('origin', 'ilike', self.name)], limit=1) + + @api.depends('order_line.product_id', 'date_order') + def _compute_et_products(self): + jakarta = pytz.timezone("Asia/Jakarta") for order in self: - if order.amount_untaxed == 0: - order.total_margin_excl_third_party = 0 + if not order.order_line or not order.date_order: + order.et_products = False continue - # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed-delivery_amt-order.fee_third_party)) * 100, 2) - order.total_margin_excl_third_party = round((order.total_before_margin / (order.amount_untaxed)) * 100, 2) - # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed)) * 100, 2) + # Ambil tanggal order sebagai basis + base_date = order.date_order + if base_date.tzinfo is None: + base_date = jakarta.localize(base_date) + else: + base_date = base_date.astimezone(jakarta) + + # Ambil nilai SLA vendor dalam hari + sla_data = order.calculate_sla_by_vendor(order.order_line) + sla_days = sla_data.get('slatime', 1) + + # Hitung ETA produk (tanpa logistik) + eta_datetime = base_date + timedelta(days=sla_days) + # Simpan ke field sebagai UTC-naive datetime (standar Odoo) + order.et_products = eta_datetime.astimezone(pytz.utc).replace(tzinfo=None) + + @api.depends('picking_ids.state', 'picking_ids.date_done') + def _compute_eta_date_reserved(self): + for order in self: + pickings = order.picking_ids.filtered( + lambda p: p.state in ('assigned', 'done') and p.date_reserved and 'BU/PICK/' in (p.name or '') + ) + done_dates = [d for d in pickings.mapped('date_done') if d] + order.eta_date_reserved = min(done_dates) if done_dates else False + # order.eta_date_reserved = min(pickings.mapped('date_done')) if pickings else False + + @api.onchange('shipping_cost_covered') + def _onchange_shipping_cost_covered(self): + if self.shipping_cost_covered == 'indoteknik' and self.select_shipping_option == 'biteship': + self.shipping_cost_covered = 'customer' + return { + 'warning': { + 'title': "Biteship Tidak Diizinkan", + 'message': ( + "Biaya pengiriman ditanggung Indoteknik, sehingga tidak diizinkan menggunakan metode Biteship. " + "Pilihan penanggung biaya akan dikembalikan sebelumnya" + ) + } + } + + def get_biteship_carrier_ids(self): + courier_codes = tuple(self._get_biteship_courier_codes() or []) + if not courier_codes: + return [] + + self.env.cr.execute(""" + SELECT delivery_carrier_id + FROM rajaongkir_kurir + WHERE name IN %s AND delivery_carrier_id IS NOT NULL + """, (courier_codes,)) + result = self.env.cr.fetchall() + carrier_ids = [row[0] for row in result if row[0]] + return carrier_ids + + # @api.model + # def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): + # res = super(SaleOrder, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) + + # if view_type == 'form': + # doc = etree.XML(res['arch']) + + # # Ambil semua delivery_carrier_id dari mapping rajaongkir_kurir + # biteship_ids = self.env['rajaongkir.kurir'].search([]).mapped('delivery_carrier_id.id') + # biteship_ids = list(set(filter(None, biteship_ids))) # pastikan unik dan bukan None + + # all_ids = self.env['delivery.carrier'].search([]).ids + # custom_ids = list(set(all_ids) - set(biteship_ids)) + + # # Format sebagai string Python list + # biteship_ids_str = ','.join(str(i) for i in biteship_ids) or '-1' + # custom_ids_str = ','.join(str(i) for i in custom_ids) or '-1' + + # # Terapkan domain ke field carrier_id + # for node in doc.xpath("//field[@name='carrier_id']"): + # # Domain tergantung select_shipping_option + # node.set( + # 'domain', + # "[('id', 'in', [%s]) if select_shipping_option == 'biteship' else ('id', 'in', [%s])]" % + # (biteship_ids_str, custom_ids_str) + # ) + + # # Simpan kembali hasil XML ke arsitektur form + # res['arch'] = etree.tostring(doc, encoding='unicode') + + # return res + + # @api.onchange('shipping_option_id') + # def _onchange_shipping_option_id(self): + # if self.shipping_option_id: + # self.delivery_amt = self.shipping_option_id.price + # self.delivery_service_type = self.shipping_option_id.courier_service_code + + def _get_biteship_courier_codes(self): + return [ + 'gojek','grab','deliveree','lalamove','jne','tiki','ninja','lion','rara','sicepat','jnt','pos','idexpress','rpx','wahana','jdl','pos','anteraja','sap','paxel','borzo' + ] + + @api.onchange('carrier_id') + def _onchange_carrier_id(self): + if not self._origin or not self._origin.id: + return + + sale_order_id = self._origin.id + self.shipping_option_id = False + + if not self.carrier_id: + return {'domain': {'shipping_option_id': [('id', '=', -1)]}} + + # Ambil provider dari mapping + self.env.cr.execute(""" + SELECT name FROM rajaongkir_kurir + WHERE delivery_carrier_id = %s LIMIT 1 + """, (self.carrier_id.id,)) + row = self.env.cr.fetchone() + provider = row[0].lower() if row and row[0] else ( + self.carrier_id.name.lower().split()[0] if self.carrier_id.name else False + ) + + _logger.info(f"[Carrier Changed] {self.carrier_id.name}, Detected Provider: {provider}") + + # ─────────────────────────────────────────────────────────────── + # Validasi koordinat untuk kurir instan + # ─────────────────────────────────────────────────────────────── + instan_kurir = ['gojek', 'grab', 'lalamove', 'borzo', 'rara', 'deliveree'] + if provider in instan_kurir: + lat = self.real_shipping_id.latitude + lng = self.real_shipping_id.longtitude + def is_invalid(val): + try: + return not val or float(val) == 0.0 + except (ValueError, TypeError): + return True + + if is_invalid(lat) or is_invalid(lng): + self.carrier_id = self._origin.carrier_id + self.shipping_option_id = self._origin.shipping_option_id or False + return { + 'warning': { + 'title': "Alamat Belum Pin Point", + 'message': ( + "Kurir instan seperti Gojek, Grab, Lalamove, Borzo, Rara, dan Deliveree " + "membutuhkan alamat pengiriman yang sudah Pin Point.\n\n" + "Silakan tentukan lokasi dengan tepat pada Pin Point Location yang tersedia di kontak." + ) + }, + 'domain': {'shipping_option_id': [('id', '=', -1)]} + } + + # ─────────────────────────────────────────────────────────────── + # Baru cek apakah shipping option sudah ada + # ─────────────────────────────────────────────────────────────── + total_so_options = self.env['shipping.option'].search_count([ + ('sale_order_id', '=', sale_order_id) + ]) + + if total_so_options == 0: + return {'domain': {'shipping_option_id': [('id', '=', -1)]}} + + # Validasi: apakah shipping option ada untuk provider ini? + matched = self.env['shipping.option'].search_count([ + ('sale_order_id', '=', sale_order_id), + ('provider', 'ilike', provider), + ]) + if self.select_shipping_option == 'biteship' and matched == 0: + self.carrier_id = self._origin.carrier_id + self.shipping_option_id = self._origin.shipping_option_id or False + return { + 'warning': { + 'title': "Shipping Option Tidak Ditemukan", + 'message': ( + "Layanan kurir ini tidak tersedia pada pengiriman ini. " + "Pilihan dikembalikan ke sebelumnya." + ) + }, + 'domain': {'shipping_option_id': [('id', '=', -1)]} + } + + # Kalau semua valid, kembalikan domain normal + domain = [ + '|', + '&', ('sale_order_id', '=', sale_order_id), ('provider', 'ilike', f'%{provider}%'), + '&', ('sale_order_id', '=', False), ('provider', 'ilike', f'%{provider}%') + ] + return {'domain': {'shipping_option_id': domain}} + + @api.onchange('shipping_option_id') + def _onchange_shipping_option_id(self): + if not self.shipping_option_id: + return + + if not self.carrier_id: + # Jika belum pilih carrier, tetap update harga dan service type + self.delivery_amt = self.shipping_option_id.price + self.delivery_service_type = self.shipping_option_id.courier_service_code + return + + # Ambil provider dari carrier + self.env.cr.execute(""" + SELECT name FROM rajaongkir_kurir + WHERE delivery_carrier_id = %s LIMIT 1 + """, (self.carrier_id.id,)) + row = self.env.cr.fetchone() + provider = row[0].lower() if row and row[0] else self.carrier_id.name.lower().split()[0] + + selected_provider = (self.shipping_option_id.provider or '').lower() + + if provider not in selected_provider: + warning_msg = { + 'title': "Opsi Tidak Valid", + 'message': f"Opsi pengiriman '{self.shipping_option_id.name}' tidak cocok dengan metode '{self.carrier_id.name}'. Dikembalikan ke sebelumnya." + } + + # Kembalikan ke nilai lama (jika record sudah disimpan) + self.shipping_option_id = self._origin.shipping_option_id if self._origin else False + return {'warning': warning_msg} + + # Jika valid + self.delivery_amt = self.shipping_option_id.price + self.delivery_service_type = self.shipping_option_id.courier_service_code + + def _update_delivery_service_type_from_shipping_option(self, vals): + shipping_option_id = vals.get('shipping_option_id') or self.shipping_option_id.id + if shipping_option_id: + shipping_option = self.env['shipping.option'].browse(shipping_option_id) + if shipping_option.exists(): + courier_service = shipping_option.courier_service_code + vals['delivery_service_type'] = courier_service + _logger.info("Set delivery_service_type: %s from shipping_option_id: %s", courier_service, shipping_option_id) + else: + _logger.warning("shipping_option_id %s not found or invalid.", shipping_option_id) + else: + _logger.info("shipping_option_id not found in vals or record.") + + # @api.model + # def fields_get(self, allfields=None, attributes=None): + # res = super().fields_get(allfields=allfields, attributes=attributes) + + # # Aktifkan hanya kalau sedang buka form Sales Order (safety check) + # if self.env.context.get('params', {}).get('model') == 'sale.order' and \ + # self.env.context.get('params', {}).get('id'): + + # sale_id = self.env.context['params']['id'] + + # # Ambil carrier_id dari SO yang sedang dibuka + # self.env.cr.execute("SELECT carrier_id FROM sale_order WHERE id = %s", (sale_id,)) + # row = self.env.cr.fetchone() + # carrier_id = row[0] if row else None + + # provider = None + # if carrier_id: + # self.env.cr.execute(""" + # SELECT name FROM rajaongkir_kurir WHERE delivery_carrier_id = %s LIMIT 1 + # """, (carrier_id,)) + # row = self.env.cr.fetchone() + # if row and row[0]: + # provider = row[0].lower() + # else: + # self.env.cr.execute("SELECT name FROM delivery_carrier WHERE id = %s", (carrier_id,)) + # row = self.env.cr.fetchone() + # provider = row[0].lower().split()[0] if row and row[0] else '' + + # if provider: + # domain = [ + # '|', + # '&', ('sale_order_id', '=', sale_id), ('provider', 'ilike', f'%{provider}%'), + # '&', ('sale_order_id', '=', False), ('provider', 'ilike', f'%{provider}%') + # ] + + # if 'shipping_option_id' in res: + # res['shipping_option_id']['domain'] = domain + # _logger.info(f"fields_get - Injected domain for shipping_option_id: {domain}") + # return res + + + @api.onchange('select_shipping_option') + def _onchange_select_shipping_option(self): + self.shipping_option_id = False + self.delivery_service_type = False + self.carrier_id = False + self.delivery_amt = 0 + + biteship_carrier_ids = [] + self.env.cr.execute(""" + SELECT delivery_carrier_id + FROM rajaongkir_kurir + WHERE name IN %s + """, (tuple(self._get_biteship_courier_codes()),)) + biteship_carrier_ids = [row[0] for row in self.env.cr.fetchall() if row[0]] + + if self.select_shipping_option == 'biteship': + if self.shipping_cost_covered == 'indoteknik': + self.select_shipping_option = self._origin.select_shipping_option if self._origin else 'custom' + return { + 'warning': { + 'title': "Biteship Tidak Diizinkan", + 'message': ( + "Biaya pengiriman ditanggung Indoteknik. Tidak diizinkan memilih metode Biteship. " + "Opsi pengiriman dikembalikan ke sebelumnya." + ) + } + } + + domain = [('id', 'in', biteship_carrier_ids)] if biteship_carrier_ids else [('id', '=', -1)] + else: + domain = [] # tampilkan semua + + return {'domain': {'carrier_id': domain}} + + # def _compute_total_margin_excl_third_party(self): + # for order in self: + # if order.amount_untaxed == 0: + # order.total_margin_excl_third_party = 0 + # continue + # + # # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed-delivery_amt-order.fee_third_party)) * 100, 2) + # order.total_margin_excl_third_party = round((order.total_before_margin / (order.amount_untaxed)) * 100, 2) + # # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed)) * 100, 2) + # def ask_retur_cancel_purchasing(self): for rec in self: if self.env.user.has_group('indoteknik_custom.group_role_purchasing'): @@ -403,24 +761,18 @@ class SaleOrder(models.Model): if len(tax_sets) > 1: raise ValidationError("Semua produk dalam Sales Order harus memiliki kombinasi pajak yang sama.") - # @api.constrains('fee_third_party', 'delivery_amt', 'biaya_lain_lain') + # @api.constrains('fee_third_party', 'delivery_amt', 'biaya_lain_lain', 'ongkir_ke_xpdc') # def _check_total_margin_excl_third_party(self): # for rec in self: # if rec.fee_third_party == 0 and rec.total_margin_excl_third_party != rec.total_percent_margin: # # Gunakan direct SQL atau flag context untuk menghindari rekursi # self.env.cr.execute(""" - # UPDATE sale_order - # SET total_margin_excl_third_party = %s + # UPDATE sale_order + # SET total_margin_excl_third_party = %s # WHERE id = %s # """, (rec.total_percent_margin, rec.id)) # self.invalidate_cache() - @api.constrains('shipping_option_id') - def _check_shipping_option(self): - for rec in self: - if rec.shipping_option_id: - rec.delivery_amt = rec.shipping_option_id.price - def _compute_shipping_method_picking(self): for order in self: if order.picking_ids: @@ -493,17 +845,93 @@ class SaleOrder(models.Model): ) def action_estimate_shipping(self): - if self.carrier_id.id in [1, 151]: - self.action_indoteknik_estimate_shipping() - return + # if self.carrier_id.id in [1, 151]: + # self.action_indoteknik_estimate_shipping() + # return + + if self.select_shipping_option == 'biteship': + return self.action_estimate_shipping_biteship() + elif self.carrier_id.id in [1, 151]: # ID untuk Indoteknik Delivery + return self.action_indoteknik_estimate_shipping() + else: + total_weight = 0 + missing_weight_products = [] + + for line in self.order_line: + if line.weight > 0: + total_weight += line.weight * line.product_uom_qty + line.product_id.weight = line.weight + else: + missing_weight_products.append(line.product_id.name) + + if missing_weight_products: + product_names = '<br/>'.join(missing_weight_products) + self.message_post(body=f"Produk berikut tidak memiliki berat:<br/>{product_names}") + + if total_weight == 0: + raise UserError("Tidak dapat mengestimasi ongkir tanpa berat yang valid.") + + kecamatan_name = self.real_shipping_id.kecamatan_id.name + kota_name = self.real_shipping_id.kota_id.name + kelurahan_name = self.real_shipping_id.kelurahan_id.name + + destination_subsdistrict_id = self._get_subdistrict_id_from_komerce(kecamatan_name, kota_name, kelurahan_name) + + # destination_subsdistrict_id = self.real_shipping_id.kecamatan_id.rajaongkir_id + if not destination_subsdistrict_id: + raise UserError("Gagal mendapatkan ID kota tujuan.") + result = self._call_rajaongkir_api(total_weight, destination_subsdistrict_id) + if not result or not result.get('data'): + raise UserError(_("Kurir %s tidak tersedia untuk tujuan ini. Silakan pilih kurir lain.") % self.carrier_id.name) + + if result: + shipping_options = [] + + for cost in result.get('data', []): + service = cost.get('service') + description = cost.get('description') + etd = cost.get('etd', '') + value = cost.get('cost', 0) + provider = cost.get('code') + + shipping_options.append((service, description, etd, value, provider)) + + self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink() + + _logger.info(f"Shipping options: {shipping_options}") + + for service, description, etd, value, provider in shipping_options: + self.env["shipping.option"].create({ + "name": service, + "price": value, + "provider": provider, + "etd": etd, + "sale_order_id": self.id, + }) + + self.shipping_option_id = self.env["shipping.option"].search([('sale_order_id', '=', self.id)], limit=1).id + + _logger.info(f"Shipping option SO ID: {self.shipping_option_id}") + + self.message_post( + body=f"Estimasi Ongkos Kirim: Rp{self.delivery_amt}<br/>Detail Lain:<br/>" + f"{'<br/>'.join([f'Service: {s[0]}, Description: {s[1]}, ETD: {s[2]}, Cost: Rp {s[3]}' for s in shipping_options])}", + message_type="comment" + ) + else: + raise UserError("Gagal mendapatkan estimasi ongkir.") + + def _validate_for_shipping_estimate(self): + # Cek berat produk total_weight = 0 missing_weight_products = [] for line in self.order_line: - if line.weight > 0: - total_weight += line.weight * line.product_uom_qty - line.product_id.weight = line.weight + product_weight = line.product_id.weight or 0 + if product_weight > 0: + total_weight += product_weight * line.product_uom_qty + line.weight = product_weight else: missing_weight_products.append(line.product_id.name) @@ -512,71 +940,314 @@ class SaleOrder(models.Model): self.message_post(body=f"Produk berikut tidak memiliki berat:<br/>{product_names}") if total_weight == 0: - raise UserError("Tidak dapat mengestimasi ongkir tanpa berat yang valid.") + raise UserError("Tidak dapat mengestimasi ongkir tanpa karena berat produk = 0 kg.") + + # Validasi alamat pengiriman + if not self.real_shipping_id: + raise UserError("Alamat pengiriman (Real Delivery Address) harus diisi.") + + if not self.real_shipping_id.kota_id: + raise UserError("Kota pada alamat pengiriman harus diisi.") + + if not self.real_shipping_id.zip: + raise UserError("Kode pos pada alamat pengiriman harus diisi.") + + if not self.real_shipping_id.state_id: + raise UserError("Provinsi pada alamat pengiriman harus diisi.") + + return total_weight + + def action_estimate_shipping_biteship(self): + total_weight = self._validate_for_shipping_estimate() + + weight_gram = int(total_weight * 1000) + if weight_gram < 100: + weight_gram = 100 + + value = int(self.amount_untaxed or sum(line.price_subtotal for line in self.order_line)) + + items = [{ + "name": "Paket Pesanan", + "description": f"Sale Order {self.name}", + "value": value, + "weight": weight_gram, + "quantity": 1, + }] + + shipping_address = self.real_shipping_id + _logger.info(f"Shipping Address: {shipping_address}") + + origin_data = { + "origin_latitude": -6.3031123, + "origin_longitude": 106.7794934, + } + + destination_data = {} + use_coordinate = False + + if hasattr(shipping_address, 'latitude') and hasattr(shipping_address, 'longtitude'): + if shipping_address.latitude and shipping_address.longtitude: + try: + lat = float(shipping_address.latitude) + lng = float(shipping_address.longtitude) + destination_data = { + "destination_latitude": lat, + "destination_longitude": lng + } + use_coordinate = True + _logger.info(f"Using coordinates: lat={lat}, lng={lng}") + except (ValueError, TypeError): + _logger.warning(f"Invalid coordinates, falling back to postal code") + use_coordinate = False + + if not use_coordinate: + if shipping_address.zip: + origin_data = {"origin_postal_code": 14440} + destination_data = { + "destination_postal_code": shipping_address.zip + } + _logger.info(f"Using postal code: {shipping_address.zip}") + else: + raise UserError("Tidak dapat mengestimasikan ongkir: Kode pos tujuan tidak tersedia.") + + couriers = ','.join(self._get_biteship_courier_codes()) + + api_mode = "koordinat" if use_coordinate else "kode_pos" + _logger.info(f"Calling Biteship API with mode: {api_mode}") + + result = self._call_biteship_api(origin_data, destination_data, items, couriers) + + if not result: + raise UserError("Gagal mendapatkan estimasi ongkir dari Biteship.") + + self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink() + + shipping_options = [] + courier_options = {} + shipping_services = result.get('pricing', []) + + _logger.info(f"Ditemukan {len(shipping_services)} layanan pengiriman") + + for service in shipping_services: + courier_code = service.get('courier_code', '').lower() + courier_name = service.get('courier_name', '') + service_name = service.get('courier_service_name', '') + raw_price = service.get('price', 0) + markup_price = int(raw_price * 1.1) + price = round(markup_price / 1000) * 1000 + + _logger.info(f"Layanan: {courier_name} - {service_name}, Harga: {price}") + + if not price: + _logger.warning(f"Melewati layanan dengan harga 0: {courier_name} - {service_name}") + continue + + duration = service.get('duration', '') + shipment_range = service.get('shipment_duration_range', '') + shipment_unit = service.get('shipment_duration_unit', 'days') + + if duration: + etd = duration + elif shipment_range: + etd = f"{shipment_range} {shipment_unit}" + else: + etd = "1-3 days" - destination_subsdistrict_id = self.real_shipping_id.kecamatan_id.rajaongkir_id - if not destination_subsdistrict_id: - raise UserError("Gagal mendapatkan ID kota tujuan.") - - result = self._call_rajaongkir_api(total_weight, destination_subsdistrict_id) - if result: - shipping_options = [] - for courier in result['rajaongkir']['results']: - for cost_detail in courier['costs']: - service = cost_detail['service'] - description = cost_detail['description'] - etd = cost_detail['cost'][0]['etd'] - value = cost_detail['cost'][0]['value'] - shipping_options.append((service, description, etd, value, courier['code'])) - - self.env["shipping.option"].search([('sale_order_id', '=', self.id)]).unlink() - - _logger.info(f"Shipping options: {shipping_options}") - - for service, description, etd, value, provider in shipping_options: - self.env["shipping.option"].create({ - "name": service, - "price": value, - "provider": provider, + try: + shipping_option = self.env["shipping.option"].create({ + "name": f"{courier_name} - {service_name}", + "price": price, + "provider": courier_code, "etd": etd, + "courier_service_code": service.get('courier_service_code'), "sale_order_id": self.id, }) - self.shipping_option_id = self.env["shipping.option"].search([('sale_order_id', '=', self.id)], limit=1).id + shipping_options.append(shipping_option) - _logger.info(f"Shipping option SO ID: {self.shipping_option_id}") + courier_upper = courier_code.upper() + if courier_upper not in courier_options: + courier_options[courier_upper] = [] + courier_options[courier_upper].append({ + "name": service_name, + "etd": etd, + "price": price + }) - self.message_post( - body=f"Estimasi Ongkos Kirim: Rp{self.delivery_amt}<br/>Detail Lain:<br/>" - f"{'<br/>'.join([f'Service: {s[0]}, Description: {s[1]}, ETD: {s[2]} hari, Cost: Rp {s[3]}' for s in shipping_options])}", - message_type="comment" - ) + _logger.info(f"Berhasil membuat opsi pengiriman: {courier_name} - {service_name}") + except Exception as e: + _logger.error(f"Gagal membuat opsi pengiriman: {str(e)}") + + if not shipping_options: + raise UserError(f"Tidak ada layanan pengiriman ditemukan untuk kode pos {destination_data.get('destination_postal_code', '')}. Mohon periksa kembali kode pos atau gunakan metode pengiriman lain.") + + # Temukan shipping option yang cocok berdasarkan carrier_id + selected_option = None - # self.message_post(body=f"Estimasi Ongkos Kirim: Rp{self.delivery_amt}<br/>Detail Lain:<br/>{'<br/>'.join([f'Service: {s[0]}, Description: {s[1]}, ETD: {s[2]} hari, Cost: Rp {s[3]}' for s in shipping_options])}", message_type="comment") + if self.carrier_id: + rajaongkir_kurir = self.env['rajaongkir.kurir'].search([ + ('delivery_carrier_id', '=', self.carrier_id.id) + ], limit=1) + if rajaongkir_kurir: + courier_code = rajaongkir_kurir.name.lower() + carrier_name = self.carrier_id.name.lower() + + possible_codes = list({ + courier_code, + carrier_name, + carrier_name.split()[0] if ' ' in carrier_name else carrier_name + }) + + _logger.info(f"[MATCHING] Mencari shipping option untuk kurir: {possible_codes}") + + for option in shipping_options: + option_provider = (option.provider or '').lower() + option_name = (option.name or '').lower() + + if any(code in option_provider or code in option_name for code in possible_codes): + selected_option = option + _logger.info(f"[MATCHED] Shipping option cocok: {option.name}") + break + + if not selected_option and shipping_options: + selected_option = shipping_options[0] + _logger.info(f"[DEFAULT] Tidak ada yang cocok, pakai opsi pertama: {selected_option.name}") + + # ❗ Ganti carrier_id hanya jika BELUM terisi sama sekali (contoh: user dari backend) + if not self.carrier_id: + provider = selected_option.provider.lower() + self.env.cr.execute(""" + SELECT delivery_carrier_id FROM rajaongkir_kurir + WHERE LOWER(name) = %s AND delivery_carrier_id IS NOT NULL + LIMIT 1 + """, (provider,)) + row = self.env.cr.fetchone() + matched_carrier_id = row[0] if row else False + if matched_carrier_id: + self.carrier_id = matched_carrier_id + _logger.info(f"[AUTO-SET] Carrier diisi otomatis ke ID {matched_carrier_id} (provider: {provider})") + else: + _logger.warning(f"[WARNING] Provider {provider} tidak ditemukan di rajaongkir_kurir") + + # Set shipping option dan nilai ongkir + if selected_option: + self.shipping_option_id = selected_option.id + self.delivery_amt = selected_option.price + self.delivery_service_type = selected_option.courier_service_code + message_lines = [f"<b>Estimasi Ongkos Kirim Biteship:</b><br/>"] + + for courier, options in courier_options.items(): + message_lines.append(f"<b>{courier}:</b><br/>") + for opt in options: + message_lines.append(f"Service: {opt['name']}, ETD: {opt['etd']}, Cost: Rp {int(opt['price']):,}<br/>") + if courier != list(courier_options.keys())[-1]: + message_lines.append("<br/>") + + origin_address = "Jl. Bandengan Utara Komp A & BRT. Penjaringan, Kec. Penjaringan, Jakarta (BELAKANG INDOMARET) KOTA JAKARTA UTARA PENJARINGAN" + destination_address = ', '.join(filter(None, [ + shipping_address.street, + shipping_address.kelurahan_id.name if shipping_address.kelurahan_id else None, + shipping_address.kecamatan_id.name if shipping_address.kecamatan_id else None, + shipping_address.kota_id.name if shipping_address.kota_id else None, + shipping_address.state_id.name if shipping_address.state_id else None + ])) + if use_coordinate: + origin_suffix = f"(Koordinat: {origin_data.get('origin_latitude')}, {origin_data.get('origin_longitude')})" + destination_suffix = f"(Koordinat: {destination_data.get('destination_latitude')}, {destination_data.get('destination_longitude')})" else: - raise UserError("Gagal mendapatkan estimasi ongkir.") + origin_suffix = f"(Kode Pos: {origin_data.get('origin_postal_code')})" + destination_suffix = f"(Kode Pos: {destination_data.get('destination_postal_code')})" + + message_lines.append("<br/><br/><br><b>Info Lokasi:</b><br/>") + message_lines.append(f"<b>Asal</b>: {origin_address} {origin_suffix}<br/>") + message_lines.append(f"<b>Tujuan</b>: {destination_address} {destination_suffix}<br/>") + + message_body = "".join(message_lines) + + self.message_post( + body=message_body, + message_type="comment" + ) + + # Simpan informasi untuk note ekspedisi + # selected_option = shipping_options[0] # Opsi pertama dipilih sebagai default + # self.note_ekspedisi = f"Pengiriman: {selected_option.name} - Rp {selected_option.price:,.0f} ({selected_option.etd}) [via {api_mode}]" + + + def _call_biteship_api(self, origin_data, destination_data, items, couriers=None): + + url = "https://api.biteship.com/v1/rates/couriers" + api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo" + # api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA" + # api_key = self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_live') + # api_key = self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_test') + headers = { + 'Authorization': api_key, + 'Content-Type': 'application/json' + } + + if not couriers: + couriers = ','.join(self._get_biteship_courier_codes()) + + # Persiapkan payload dengan menggabungkan origin, destination, dan items + payload = { + **origin_data, + **destination_data, + "couriers": couriers, + "items": items + } + + api_mode = "koordinat" if "destination_latitude" in destination_data else "kode_pos" + + try: + _logger.info(f"Calling Biteship API with mode: {api_mode}") + _logger.info(f"Payload: {payload}") + + response = requests.post(url, headers=headers, json=payload, timeout=30) + + _logger.info(f"Biteship API Status Code: {response.status_code}") + if response.status_code != 200: + _logger.error(f"Biteship API Error Response: {response.text}") + + if response.status_code == 200: + result = response.json() + result['api_mode'] = api_mode # Tambahkan info mode API + return result + else: + error_msg = response.text + _logger.error(f"Error calling Biteship API: {response.status_code} - {error_msg}") + return False + except Exception as e: + _logger.error(f"Exception calling Biteship API: {str(e)}") + return False + def _call_rajaongkir_api(self, total_weight, destination_subsdistrict_id): - url = 'https://pro.rajaongkir.com/api/cost' + url = 'https://rajaongkir.komerce.id/api/v1/calculate/domestic-cost' headers = { 'key': '9b1310f644056d84d60b0af6bb21611a', } courier = self.carrier_id.name.lower() data = { - 'origin': 2127, - 'originType': 'subdistrict', + 'origin': 17656, + # 'originType': 'subdistrict', 'destination': int(destination_subsdistrict_id), - 'destinationType': 'subdistrict', + # 'destinationType': 'subdistrict', 'weight': int(total_weight * 1000), 'courier': courier, } - response = requests.post(url, headers=headers, data=data) - if response.status_code == 200: - return response.json() - return None + try: + _logger.info(f"Calling RajaOngkir API with data: {data}") + response = requests.post(url, headers=headers, data=data) + _logger.info(f"RajaOngkir response: {response.status_code} - {response.text}") + + if response.status_code == 200: + return response.json() + except Exception as e: + _logger.error(f"Exception while calling RajaOngkir: {str(e)}") def _normalize_city_name(self, city_name): city_name = city_name.lower() @@ -590,37 +1261,82 @@ class SaleOrder(models.Model): return city_name - def _get_city_id_by_name(self, city_name): - url = 'https://pro.rajaongkir.com/api/city' + # def _get_city_id_by_name(self, city_name): + # url = 'https://pro.rajaongkir.com/api/city' + # headers = { + # 'key': '9b1310f644056d84d60b0af6bb21611a', + # } + + # normalized_city_name = self._normalize_city_name(city_name) + + # response = requests.get(url, headers=headers) + # if response.status_code == 200: + # city_data = response.json() + # for city in city_data['rajaongkir']['results']: + # if city['city_name'].lower() == normalized_city_name: + # return city['city_id'] + # return None + + # def _get_subdistrict_id_by_name(self, city_id, subdistrict_name): + # url = f'https://pro.rajaongkir.com/api/subdistrict?city={city_id}' + # headers = { + # 'key': '9b1310f644056d84d60b0af6bb21611a', + # } + + # response = requests.get(url, headers=headers) + # if response.status_code == 200: + # subdistrict_data = response.json() + # for subdistrict in subdistrict_data['rajaongkir']['results']: + # subsdistrict_1 = subdistrict['subdistrict_name'].lower() + # subsdistrict_2 = subdistrict_name.lower() + + # if subsdistrict_1 == subsdistrict_2: + # return subdistrict['subdistrict_id'] + # return None + + def _get_subdistrict_id_from_komerce(self, kecamatan_name, kota_name, kelurahan_name=None): + url = 'https://rajaongkir.komerce.id/api/v1/destination/domestic-destination' headers = { 'key': '9b1310f644056d84d60b0af6bb21611a', } - normalized_city_name = self._normalize_city_name(city_name) - - response = requests.get(url, headers=headers) - if response.status_code == 200: - city_data = response.json() - for city in city_data['rajaongkir']['results']: - if city['city_name'].lower() == normalized_city_name: - return city['city_id'] - return None + if kelurahan_name: + search = f"{kelurahan_name} {kecamatan_name} {kota_name}" + else: + search = f"{kecamatan_name} {kota_name}" - def _get_subdistrict_id_by_name(self, city_id, subdistrict_name): - url = f'https://pro.rajaongkir.com/api/subdistrict?city={city_id}' - headers = { - 'key': '9b1310f644056d84d60b0af6bb21611a', + params = { + 'search': search, + 'limit': 5 } - response = requests.get(url, headers=headers) - if response.status_code == 200: - subdistrict_data = response.json() - for subdistrict in subdistrict_data['rajaongkir']['results']: - subsdistrict_1 = subdistrict['subdistrict_name'].lower() - subsdistrict_2 = subdistrict_name.lower() + try: + response = requests.get(url, headers=headers, params=params, timeout=10) + if response.status_code == 200: + data = response.json().get('data', []) + _logger.info(f"[Komerce] Fetched {len(data)} subdistricts for search '{search}'") + _logger.info(f"[Komerce] Response: {data}") + + normalized_kota = self._normalize_city_name(kota_name) + + for item in data: + match_kelurahan = ( + not kelurahan_name or + item.get('subdistrict_name', '').lower() == kelurahan_name.lower() + ) + if ( + match_kelurahan and + item.get('district_name', '').lower() == kecamatan_name.lower() and + item.get('city_name', '').lower() == normalized_kota + ): + return item.get('id') + + _logger.warning(f"[Komerce] No match for '{kecamatan_name}' in city '{kota_name}' with kelurahan '{kelurahan_name}'") + else: + _logger.error(f"[Komerce] HTTP Error {response.status_code}: {response.text}") + except Exception as e: + _logger.error(f"[Komerce] Exception: {e}") - if subsdistrict_1 == subsdistrict_2: - return subdistrict['subdistrict_id'] return None def _compute_type_promotion(self): @@ -684,38 +1400,102 @@ class SaleOrder(models.Model): rec.compute_fullfillment = True - @api.depends('date_order', 'estimated_arrival_days', 'state', 'estimated_arrival_days_start') + @api.depends('expected_ready_to_ship', 'shipping_option_id.etd', 'state') def _compute_eta_date(self): - current_date = datetime.now().date() for rec in self: - if rec.date_order and rec.state not in [ - 'cancel'] and rec.estimated_arrival_days and rec.estimated_arrival_days_start: - rec.eta_date = current_date + timedelta(days=rec.estimated_arrival_days) - rec.eta_date_start = current_date + timedelta(days=rec.estimated_arrival_days_start) + if rec.expected_ready_to_ship and rec.shipping_option_id and rec.shipping_option_id.etd and rec.state not in ['cancel']: + etd_text = rec.shipping_option_id.etd.strip().lower() + match = re.match(r"(\d+)\s*-\s*(\d+)\s*(days?|hours?)", etd_text) + single_match = re.match(r"(\d+)\s*(days?|hours?)", etd_text) + + if match: + start_val = int(match.group(1)) + end_val = int(match.group(2)) + unit = match.group(3) + + if 'hour' in unit: + rec.eta_date_start = rec.expected_ready_to_ship + timedelta(hours=start_val) + rec.eta_date = rec.expected_ready_to_ship + timedelta(hours=end_val) + else: + rec.eta_date_start = rec.expected_ready_to_ship + timedelta(days=start_val) + rec.eta_date = rec.expected_ready_to_ship + timedelta(days=end_val) + + elif single_match: + val = int(single_match.group(1)) + unit = single_match.group(2) + + if 'hour' in unit: + rec.eta_date_start = rec.expected_ready_to_ship + timedelta(hours=val) + rec.eta_date = rec.expected_ready_to_ship + timedelta(hours=val) + else: + rec.eta_date_start = rec.expected_ready_to_ship + timedelta(days=val) + rec.eta_date = rec.expected_ready_to_ship + timedelta(days=val) + + else: + rec.eta_date_start = False + rec.eta_date = False else: - rec.eta_date = False rec.eta_date_start = False - + rec.eta_date = False + + def get_days_until_next_business_day(self, start_date=None, *args, **kwargs): - today = start_date or datetime.today().date() - offset = 0 # Counter jumlah hari yang ditambahkan - holiday = self.env['hr.public.holiday'] + jakarta = pytz.timezone("Asia/Jakarta") + now = datetime.now(jakarta) - while True: - today += timedelta(days=1) - offset += 1 + if start_date is None: + start_date = now - if today.weekday() >= 5: - continue + if start_date.tzinfo is None: + start_date = jakarta.localize(start_date) - is_holiday = holiday.search([("start_date", "=", today)]) - if is_holiday: - continue + holiday = self.env['hr.public.holiday'] + batas_waktu = datetime.strptime("15:00", "%H:%M").time() + current_day = start_date.date() + offset = 0 + is3pm = False + + # Step 1: Lewat jam 15 → Tambah 1 hari + if start_date.time() > batas_waktu: + is3pm = True + offset += 1 - break + # Step 2: Hitung hari libur selama offset itu + i = 0 + total_days = 0 + while i < offset: + current_day += timedelta(days=1) + total_days += 1 + is_weekend = current_day.weekday() >= 5 + is_holiday = holiday.search([("start_date", "=", current_day)]) + if not is_weekend and not is_holiday: + i += 1 # hanya hitung hari kerja + + # Step 3: Tambah 1 hari masa persiapan gudang + i = 0 + while i < 1: + current_day += timedelta(days=1) + total_days += 1 + is_weekend = current_day.weekday() >= 5 + is_holiday = holiday.search([("start_date", "=", current_day)]) + if not is_weekend and not is_holiday: + i += 1 + + # Step 4: Kalau current_day ternyata weekend/libur, cari hari kerja berikutnya + while True: + is_weekend = current_day.weekday() >= 5 + is_holiday = holiday.search([("start_date", "=", current_day)]) + if is_weekend or is_holiday: + current_day += timedelta(days=1) + total_days += 1 + else: + break + + offset = (current_day - start_date.date()).days + return offset, is3pm - return offset + def calculate_sla_by_vendor(self, products): product_ids = products.mapped('product_id.id') # Kumpulkan semua ID produk include_instant = True # Default True, tetapi bisa menjadi False @@ -724,7 +1504,7 @@ class SaleOrder(models.Model): all_fast_products = all( product.product_id.qty_free_bandengan >= product.product_uom_qty for product in products) if all_fast_products: - return {'slatime': 1, 'include_instant': include_instant} + return {'slatime': 0, 'include_instant': include_instant} # Cari semua vendor pemenang untuk produk yang diberikan vendors = self.env['purchase.pricelist'].search([ @@ -758,48 +1538,109 @@ class SaleOrder(models.Model): if not rec.date_order: rec.expected_ready_to_ship = False return - - current_date = datetime.now().date() - + + jakarta = pytz.timezone("Asia/Jakarta") + current_date = datetime.now(jakarta) + max_slatime = 1 # Default SLA jika tidak ada slatime = self.calculate_sla_by_vendor(rec.order_line) max_slatime = max(max_slatime, slatime['slatime']) - - sum_days = max_slatime + self.get_days_until_next_business_day(current_date) - 1 + + offset , is3pm = self.get_days_until_next_business_day(current_date) + sum_days = max_slatime + offset + sum_days -= 1 if not rec.estimated_arrival_days: rec.estimated_arrival_days = sum_days eta_date = current_date + timedelta(days=sum_days) + if is3pm: + eta_date = datetime.combine(eta_date, time(10, 0)) # jam 10:00 + eta_date = jakarta.localize(eta_date).astimezone(timezone.utc) # ubah ke UTC + + + eta_date = eta_date.astimezone(timezone.utc).replace(tzinfo=None) rec.commitment_date = eta_date rec.expected_ready_to_ship = eta_date @api.depends("order_line.product_id", "date_order") def _compute_etrts_date(self): # Function to calculate Estimated Ready To Ship Date self._calculate_etrts_date() + + + # def _validate_expected_ready_ship_date(self): + # for rec in self: + # if not rec.order_line: + # _logger.info("⏩ Lewati validasi ERTS karena belum ada produk.") + # return # Lewati validasi jika belum ada produk + + # now = fields.Datetime.now() + # expected_date = rec.expected_ready_to_ship and rec.expected_ready_to_ship.date() or None + # if not expected_date: + # return # Tidak validasi jika tidak ada input sama sekali + + # sla = rec.calculate_sla_by_vendor() + # offset_day, lewat_jam_3 = rec.get_days_until_next_business_day() + # eta_minimum = now + timedelta(days=sla + offset_day) + + # if expected_date < eta_minimum.date(): + # rec.expected_ready_to_ship = eta_minimum + # raise ValidationError( + # "Tanggal 'Expected Ready to Ship' tidak boleh lebih kecil dari {}. Mohon pilih tanggal minimal {}." + # .format(eta_minimum.strftime('%d-%m-%Y'), eta_minimum.strftime('%d-%m-%Y')) + # ) def _validate_expected_ready_ship_date(self): + """ + Pastikan expected_ready_to_ship tidak lebih awal dari SLA minimum. + Dipanggil setiap onchange / simpan SO. + """ for rec in self: - if rec.expected_ready_to_ship and rec.commitment_date: - current_date = datetime.now().date() - # Hanya membandingkan tanggal saja, tanpa jam - expected_date = rec.expected_ready_to_ship.date() - - max_slatime = 1 # Default SLA jika tidak ada - slatime = self.calculate_sla_by_vendor(rec.order_line) - max_slatime = max(max_slatime, slatime['slatime']) - sum_days = max_slatime + self.get_days_until_next_business_day(current_date) - 1 - eta_minimum = current_date + timedelta(days=sum_days) - - if expected_date < eta_minimum: - rec.expected_ready_to_ship = eta_minimum - raise ValidationError( - "Tanggal 'Expected Ready to Ship' tidak boleh lebih kecil dari {}. Mohon pilih tanggal minimal {}." - .format(eta_minimum.strftime('%d-%m-%Y'), eta_minimum.strftime('%d-%m-%Y')) - ) - else: - rec.commitment_date = rec.expected_ready_to_ship + # ───────────────────────────────────────────────────── + # 1. Hanya validasi kalau field sudah terisi + # (quotation baru / belum ada tanggal → abaikan) + # ───────────────────────────────────────────────────── + if not rec.expected_ready_to_ship: + continue + + current_date = datetime.now() + + # ───────────────────────────────────────────────────── + # 2. Hitung SLA berdasarkan product lines (jika ada) + # ───────────────────────────────────────────────────── + products = rec.order_line + if products: + sla_data = rec.calculate_sla_by_vendor(products) + max_sla_time = sla_data.get('slatime', 1) + else: + # belum ada item → gunakan default 1 hari + max_sla_time = 1 + + # offset hari libur / weekend + offset, is3pm = rec.get_days_until_next_business_day(current_date) + min_days = max_sla_time + offset - 1 + eta_minimum = current_date + timedelta(days=min_days) + + # ───────────────────────────────────────────────────── + # 3. Validasi - raise error bila terlalu cepat + # ───────────────────────────────────────────────────── + if rec.expected_ready_to_ship.date() < eta_minimum.date(): + # set otomatis ke tanggal minimum supaya user tidak perlu + # menekan Save dua kali + rec.expected_ready_to_ship = eta_minimum + + raise ValidationError( + _("Tanggal 'Expected Ready to Ship' tidak boleh " + "lebih kecil dari %(tgl)s. Mohon pilih minimal %(tgl)s.") + % {'tgl': eta_minimum.strftime('%d-%m-%Y')} + ) + else: + # sinkronkan ke field commitment_date + rec.commitment_date = rec.expected_ready_to_ship - @api.onchange('expected_ready_to_ship') # Hangle Onchange form Expected Ready to Ship + + + + @api.onchange('expected_ready_to_ship') #Hangle Onchange form Expected Ready to Ship def _onchange_expected_ready_ship_date(self): self._validate_expected_ready_ship_date() @@ -834,6 +1675,7 @@ class SaleOrder(models.Model): 'campaign_id': self.campaign_id.id, 'medium_id': self.medium_id.id, 'source_id': self.source_id.id, + 'down_payment': 229625 in [line.product_id.id for line in self.order_line], 'user_id': self.user_id.id, 'sale_id': self.id, 'invoice_user_id': self.user_id.id, @@ -866,7 +1708,6 @@ class SaleOrder(models.Model): def _validate_delivery_amt(self): is_indoteknik = self.carrier_id.id == 1 or self.shipping_cost_covered == 'indoteknik' is_active_id = not self.env.context.get('active_id', []) - if is_indoteknik and is_active_id: if self.delivery_amt == 0: if self.carrier_id.id == 1: @@ -1035,11 +1876,11 @@ class SaleOrder(models.Model): line_no += 1 line.line_no = line_no - def write(self, vals): - if 'carrier_id' in vals: - for picking in self.picking_ids: - if picking.state == 'assigned': - picking.carrier_id = self.carrier_id + # def write(self, vals): + # if 'carrier_id' in vals: + # for picking in self.picking_ids: + # if picking.state == 'assigned': + # picking.carrier_id = self.carrier_id def calculate_so_status(self): so_state = ['sale'] @@ -1157,12 +1998,12 @@ class SaleOrder(models.Model): helper_ids_str = self.env['ir.config_parameter'].sudo().get_param('sale.order.user_helper_ids') return helper_ids_str.split(', ') - def write(self, values): - helper_ids = self._get_helper_ids() - if str(self.env.user.id) in helper_ids: - values['helper_by_id'] = self.env.user.id - - return super(SaleOrder, self).write(values) + # def write(self, values): + # helper_ids = self._get_helper_ids() + # if str(self.env.user.id) in helper_ids: + # values['helper_by_id'] = self.env.user.id + # + # return super(SaleOrder, self).write(values) def check_due(self): """To show the due amount and warning stage""" @@ -1236,9 +2077,9 @@ class SaleOrder(models.Model): confirmed_bom = search_bom.filtered(lambda x: x.state == 'confirmed' or x.state == 'done') if not confirmed_bom: raise UserError( - "Product BOM belum dikonfirmasi di Manufacturing Orders. Silakan hubungi MD.") + "Product BOM belum dikonfirmasi di Manufacturing Orders. Silakan hubungi Purchasing.") else: - raise UserError("Product BOM tidak di temukan di manufacturing orders, silahkan hubungi MD") + raise UserError("Product BOM tidak di temukan di manufacturing orders, silahkan hubungi Purchasing") def check_duplicate_product(self): for order in self: @@ -1259,6 +2100,7 @@ class SaleOrder(models.Model): self._validate_order() for order in self: + order._validate_delivery_amt() order._validate_uniform_taxes() order.order_line.validate_line() order.check_data_real_delivery_address() @@ -1501,6 +2343,7 @@ class SaleOrder(models.Model): def action_confirm(self): for order in self: + order._validate_delivery_amt() order._validate_uniform_taxes() order.check_duplicate_product() order.check_product_bom() @@ -1691,20 +2534,95 @@ class SaleOrder(models.Model): total_before_margin = sum(line.item_before_margin for line in order.order_line if line.product_id) order.total_before_margin = total_before_margin + # Perhitungan Lama + # def _compute_total_percent_margin(self): + # for order in self: + # if order.amount_untaxed == 0: + # order.total_percent_margin = 0 + # continue + # if order.shipping_cost_covered == 'indoteknik': + # delivery_amt = order.delivery_amt + # else: + # delivery_amt = 0 + # + # net_margin = order.total_margin - order.biaya_lain_lain + # + # order.total_percent_margin = round( + # (net_margin / (order.amount_untaxed - order.fee_third_party)) * 100, 2) + + # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed-delivery_amt-order.fee_third_party)) * 100, 2) + # order.total_percent_margin = round( + # (order.total_margin / (order.amount_untaxed - order.fee_third_party - order.biaya_lain_lain)) * 100, 2) + # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed)) * 100, 2) + def _compute_total_percent_margin(self): for order in self: if order.amount_untaxed == 0: order.total_percent_margin = 0 continue + if order.shipping_cost_covered == 'indoteknik': delivery_amt = order.delivery_amt else: delivery_amt = 0 - # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed-delivery_amt-order.fee_third_party)) * 100, 2) - order.total_percent_margin = round( - (order.total_margin / (order.amount_untaxed - order.fee_third_party - order.biaya_lain_lain)) * 100, 2) - # order.total_percent_margin = round((order.total_margin / (order.amount_untaxed)) * 100, 2) + net_margin = order.total_margin - order.fee_third_party - order.biaya_lain_lain + + + if order.amount_untaxed > 0: + order.total_percent_margin = round((net_margin / order.amount_untaxed) * 100, 2) + else: + order.total_percent_margin = 0 + + # @api.onchange('biaya_lain_lain') + # def _onchange_biaya_lain_lain(self): + # """Ketika biaya_lain_lain berubah, simpan nilai margin sebelumnya""" + # if hasattr(self, '_origin') and self._origin.id: + # # Hitung margin sebelum biaya_lain_lain ditambahkan + # if self.amount_untaxed > 0: + # original_net_margin = self.total_margin # tanpa biaya_lain_lain + # self.total_margin_excl_third_party = round( + # (original_net_margin / (self.amount_untaxed - self.fee_third_party)) * 100, 2) + + def _prepare_before_margin_values(self, vals): + margin_sebelumnya = {} + + margin_affecting_fields = [ + 'biaya_lain_lain', 'fee_third_party', 'delivery_amt', + 'ongkir_ke_xpdc', 'shipping_cost_covered', 'order_line' + ] + + if not any(field in vals for field in margin_affecting_fields): + return {} + + for order in self: + if order.amount_untaxed <= 0: + continue + + current_before = order.total_margin_excl_third_party or 0 + + # CASE 1: Before margin masih kosong → ambil dari item_percent_margin + if current_before == 0: + line_margin = 0 + for line in order.order_line: + if line.item_percent_margin is not None: + line_margin = line.item_percent_margin + break + margin_sebelumnya[order.id] = line_margin + _logger.info(f"[BEFORE] SO {order.name}: Before margin kosong, ambil dari order line: {line_margin}%") + else: + # CASE 2: Ada perubahan field yang mempengaruhi margin + for field in margin_affecting_fields: + if field in vals: + old_val = getattr(order, field, 0) or 0 + new_val = vals[field] or 0 + if old_val != new_val: + margin_sebelumnya[order.id] = order.total_percent_margin + _logger.info( + f"[BEFORE] SO {order.name}: {field} berubah dari {old_val} ke {new_val}, simpan {order.total_percent_margin}%") + break + + return margin_sebelumnya @api.onchange('sales_tax_id') def onchange_sales_tax_id(self): @@ -1956,13 +2874,82 @@ class SaleOrder(models.Model): order_line.discount = discount order_line.order_id.use_button = True + def _auto_set_shipping_from_website(self): + if not self.env.context.get('from_website_checkout'): + return + + for order in self: + # Validasi source website + if not order.source_id or order.source_id.id != 59: + continue + + # Skip jika Self Pick Up + if int(order.carrier_id.id or 0) == 32: + _logger.info(f"[Checkout] Skip estimasi: Self Pickup untuk SO {order.name}") + order.select_shipping_option = 'custom' + continue + + # Simpan pilihan user sebelum estimasi + user_carrier_id = order.carrier_id.id if order.carrier_id else None + user_service = order.delivery_service_type + user_amount = order.delivery_amt + + # Jalankan estimasi untuk refresh data + order.select_shipping_option = 'biteship' + order.action_estimate_shipping() + + # Restore pilihan user setelah estimasi + if user_carrier_id and user_service: + # Dapatkan provider + self.env.cr.execute("SELECT name FROM rajaongkir_kurir WHERE delivery_carrier_id = %s LIMIT 1", (user_carrier_id,)) + result = self.env.cr.fetchone() + provider = result[0].lower() if result else order.env['delivery.carrier'].browse(user_carrier_id).name.lower().split()[0] + + # Cari opsi yang cocok (prioritas: service code > nama > harga > fallback) + domain_options = [ + [('courier_service_code', '=', user_service), ('provider', 'ilike', provider)], # exact service + [('name', 'ilike', user_service), ('provider', 'ilike', provider)], # nama service + [('price', '=', user_amount), ('provider', 'ilike', provider)] if user_amount > 0 else None, # harga sama + [('provider', 'ilike', provider)] # fallback + ] + + matched_option = None + for domain in domain_options: + if domain: + matched_option = self.env['shipping.option'].search([('sale_order_id', '=', order.id)] + domain, limit=1) + if matched_option: + break + + # Set opsi yang cocok atau buat manual + if matched_option: + order.shipping_option_id = matched_option.id + order.delivery_amt = matched_option.price + order.delivery_service_type = matched_option.courier_service_code + + # Notif jika harga berubah + if user_amount > 0 and abs(matched_option.price - user_amount) > 1000: + order.message_post(body=f"Harga shipping berubah dari Rp {user_amount:,} ke Rp {matched_option.price:,}") + + elif user_amount > 0: + # Buat opsi manual jika tidak ada yang cocok + manual_option = self.env['shipping.option'].create({ + 'name': f"{provider.upper()} - {user_service}", + 'price': user_amount, + 'provider': provider, + 'courier_service_code': user_service, + 'sale_order_id': order.id, + }) + order.shipping_option_id = manual_option.id + @api.model def create(self, vals): # Ensure partner details are updated when a sale order is created order = super(SaleOrder, self).create(vals) + # _logger.info(f"[CREATE CONTEXT] {self.env.context}") + # order._auto_set_shipping_from_website() order._compute_etrts_date() order._validate_expected_ready_ship_date() - order._validate_delivery_amt() + # order._validate_delivery_amt() # order._check_total_margin_excl_third_party() # order._update_partner_details() return order @@ -1995,31 +2982,42 @@ class SaleOrder(models.Model): 'customer_type': partner.customer_type, }) - def write(self, vals): - for order in self: - if order.state in ['sale', 'cancel']: - if 'order_line' in vals: - new_lines = vals.get('order_line', []) - for command in new_lines: - if command[0] == 0: # A new line is being added - raise UserError( - "SO tidak dapat ditambahkan produk baru karena SO sudah menjadi sale order.") - - res = super(SaleOrder, self).write(vals) - # self._check_total_margin_excl_third_party() - if any(fields in vals for fields in ['delivery_amt', 'carrier_id', 'shipping_cost_covered']): - self._validate_delivery_amt() - if any(field in vals for field in ["order_line", "client_order_ref"]): - self._calculate_etrts_date() - return res + # def write(self, vals): + # for order in self: + # if order.state in ['sale', 'cancel']: + # if 'order_line' in vals: + # new_lines = vals.get('order_line', []) + # for command in new_lines: + # if command[0] == 0: # A new line is being added + # raise UserError( + # "SO tidak dapat ditambahkan produk baru karena SO sudah menjadi sale order.") + # + # res = super(SaleOrder, self).write(vals) + # # self._check_total_margin_excl_third_party() + # if any(fields in vals for fields in ['delivery_amt', 'carrier_id', 'shipping_cost_covered']): + # self._validate_delivery_amt() + # if any(field in vals for field in ["order_line", "client_order_ref"]): + # self._calculate_etrts_date() + # return res # @api.depends('commitment_date') def _compute_ready_to_ship_status_detail(self): + def is_empty(val): + """Helper untuk cek data kosong yang umum di Odoo.""" + return val is None or val == "" or val == [] or val == {} + for order in self: + order.ready_to_ship_status_detail = 'On Track' # Default value + + # Skip if no commitment date + if is_empty(order.commitment_date): + continue + eta = order.commitment_date match_lines = self.env['purchase.order.sales.match'].search([ ('sale_id', '=', order.id) ]) + if match_lines: for match in match_lines: po = match.purchase_order_id @@ -2028,17 +3026,219 @@ class SaleOrder(models.Model): ('order_id', '=', po.id), ('product_id', '=', product.id) ], limit=1) + + if is_empty(po_line): + continue + stock_move = self.env['stock.move'].search([ ('purchase_line_id', '=', po_line.id) ], limit=1) + + if is_empty(stock_move) or is_empty(stock_move.picking_id): + continue + picking_in = stock_move.picking_id - result_date = picking_in.date_done if picking_in else None - if result_date: - status = "Early" if result_date < eta else "Delay" - result_date_str = result_date.strftime('%m/%d/%Y') - eta_str = eta.strftime('%m/%d/%Y') - order.ready_to_ship_status_detail = f"Expected: {eta_str} | Realtime: {result_date_str} | {status}" - else: - order.ready_to_ship_status_detail = "On Track" - else: - order.ready_to_ship_status_detail = 'On Track'
\ No newline at end of file + result_date = picking_in.date_done + + if is_empty(result_date): + continue + + try: + if result_date < eta: + order.ready_to_ship_status_detail = f"Early (Actual: {result_date.strftime('%m/%d/%Y')})" + else: + order.ready_to_ship_status_detail = f"Delay (Actual: {result_date.strftime('%m/%d/%Y')})" + except Exception as e: + _logger.error(f"Error computing ready to ship status: {str(e)}") + continue + + def write(self, vals): + + margin_sebelumnya = self._prepare_before_margin_values(vals) + + for order in self: + if order.state in ['sale', 'cancel']: + if 'order_line' in vals: + for command in vals.get('order_line', []): + if command[0] == 0: + raise UserError( + "SO tidak dapat ditambahkan produk baru karena SO sudah menjadi sale order.") + + order._update_delivery_service_type_from_shipping_option(vals) + + if 'carrier_id' in vals: + for order in self: + for picking in order.picking_ids: + 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: + vals['helper_by_id'] = self.env.user.id + except: + pass + + res = super(SaleOrder, self).write(vals) + + # Update before margin setelah write + if margin_sebelumnya: + for order_id, margin_value in margin_sebelumnya.items(): + _logger.info(f"[UPDATE] SO ID {order_id}: Set before margin ke {margin_value}%") + self.env.cr.execute(""" + UPDATE sale_order + SET total_margin_excl_third_party = %s + WHERE id = %s + """, (margin_value, order_id)) + + self.env.cr.commit() + self.invalidate_cache(['total_margin_excl_third_party']) + + # Validasi setelah write + if any(field in vals for field in ['delivery_amt', 'carrier_id', 'shipping_cost_covered']): + self._validate_delivery_amt() + + if any(field in vals for field in ["order_line", "client_order_ref"]): + self._calculate_etrts_date() + + return res + + def button_refund(self): + self.ensure_one() + + invoice_ids = self.invoice_ids.filtered(lambda inv: inv.state != 'cancel') + + return { + 'name': 'Refund Sale Order', + 'type': 'ir.actions.act_window', + 'res_model': 'refund.sale.order', + 'view_mode': 'form', + 'target': 'current', + 'context': { + 'default_sale_order_ids': [(6, 0, [self.id])], + 'default_invoice_ids': [(6, 0, invoice_ids.ids)], + 'default_uang_masuk': sum(invoice_ids.mapped('amount_total')) + (self.delivery_amt or 0.0) + 1000, + 'default_ongkir': self.delivery_amt or 0.0, + 'default_bank': '', # bisa isi default bank kalau mau + 'default_account_name': '', + 'default_account_no': '', + 'default_refund_type': '', + }, + } + + def open_form_multi_create_refund(self): + if not self: + raise UserError("Tidak ada Sale Order yang dipilih.") + + partner_set = set(self.mapped('partner_id.id')) + if len(partner_set) > 1: + raise UserError("Tidak dapat membuat refund untuk Multi SO dengan Customer berbeda. Harus memiliki Customer yang sama.") + + invoice_status_set = set(self.mapped('invoice_status')) + if len(invoice_status_set) > 1: + raise UserError("Tidak dapat membuat refund untuk SO dengan status invoice berbeda. Harus memiliki status invoice yang sama.") + + already_refunded = self.filtered(lambda so: so.has_refund) + if already_refunded: + so_names = ', '.join(already_refunded.mapped('name')) + raise UserError(f"❌ Tidak bisa refund ulang. {so_names} sudah melakukan refund.") + + invoice_ids = self.mapped('invoice_ids').filtered(lambda inv: inv.state != 'cancel') + delivery_total = sum(self.mapped('delivery_amt')) + total_invoice = sum(invoice_ids.mapped('amount_total')) + + return { + 'type': 'ir.actions.act_window', + 'name': 'Create Refund', + 'res_model': 'refund.sale.order', + 'view_mode': 'form', + 'target': 'current', + 'context': { + 'default_sale_order_ids': [(6, 0, self.ids)], + 'default_invoice_ids': [(6, 0, invoice_ids.ids)], + 'default_uang_masuk': total_invoice + delivery_total + 1000, + 'default_ongkir': delivery_total, + 'default_bank': '', + 'default_account_name': '', + 'default_account_no': '', + 'default_refund_type': '', + } + } + + @api.depends('refund_ids') + def _compute_has_refund(self): + for so in self: + so.has_refund = bool(so.refund_ids) + + def action_view_related_refunds(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Refunds', + 'res_model': 'refund.sale.order', + 'view_mode': 'tree,form', + 'domain': [('sale_order_ids', 'in', [self.id])], + 'context': {'default_sale_order_ids': [self.id]}, + } + + def _compute_refund_ids(self): + for order in self: + refunds = self.env['refund.sale.order'].search([ + ('sale_order_ids', 'in', [order.id]) + ]) + order.refund_ids = refunds + + def _compute_refund_count(self): + for order in self: + order.refund_count = self.env['refund.sale.order'].search_count([ + ('sale_order_ids', 'in', order.id) + ]) + + @api.depends('invoice_ids') + def _compute_advance_payment_move(self): + for order in self: + move = self.env['account.move'].search([ + ('sale_id', '=', order.id), + ('journal_id', '=', 11), + ('state', '=', 'posted'), + ], limit=1, order="id desc") + order.advance_payment_move_id = move + + @api.depends('invoice_ids') + def _compute_advance_payment_moves(self): + for order in self: + moves = self.env['account.move'].search([ + ('sale_id', '=', order.id), + ('journal_id', '=', 11), + ('state', '=', 'posted'), + ]) + order.advance_payment_move_ids = moves + + @api.depends('invoice_ids') + def _compute_advance_payment_moves(self): + for order in self: + moves = self.env['account.move'].search([ + ('sale_id', '=', order.id), + ('journal_id', '=', 11), + ('state', '=', 'posted'), + ]) + order.advance_payment_move_ids = moves + order.advance_payment_move_count = len(moves) + + def action_open_advance_payment_moves(self): + self.ensure_one() + moves = self.advance_payment_move_ids + if not moves: + return + return { + 'type': 'ir.actions.act_window', + 'name': 'Journals Sales Order', + 'res_model': 'account.move', + 'view_mode': 'tree,form', + 'domain': [('id', 'in', moves.ids)], + 'target': 'current', + }
\ No newline at end of file diff --git a/indoteknik_custom/models/sale_order_delay.py b/indoteknik_custom/models/sale_order_delay.py new file mode 100644 index 00000000..dfd94650 --- /dev/null +++ b/indoteknik_custom/models/sale_order_delay.py @@ -0,0 +1,31 @@ +from odoo import api, fields, models + + +class SaleOrderDelay(models.Model): + _name = 'sale.order.delay' + _description = 'Sale Order Delay' + _primary_key = 'so_number' + + so_number = fields.Char(string="SO Number", required=True) + days_delayed = fields.Integer(string="Day Delayed or Erly") + status = fields.Selection([ + ('delayed', 'Delayed'), + ('on track', 'On Track'), + ('early', 'Early') + ], string='Status', required=True) + + _sql_constraints = [ + ('unique_so_number', 'unique(so_number)', 'SO Number must be unique!') + ] + + def update_delay(self): + query = "SELECT check_so_delay();" + self.env.cr.execute(query) + + @api.model + def create(self, vals): + return super(SaleOrderDelay, self).create(vals) + + def write(self, vals): + return super(SaleOrderDelay, self).write(vals) +
\ No newline at end of file diff --git a/indoteknik_custom/models/sale_order_line.py b/indoteknik_custom/models/sale_order_line.py index 9247d1c1..5e9fc362 100644 --- a/indoteknik_custom/models/sale_order_line.py +++ b/indoteknik_custom/models/sale_order_line.py @@ -5,20 +5,26 @@ 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") - item_percent_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") + item_percent_margin = fields.Float('%Margin', compute='compute_item_margin', + help="Total % Margin in Sales Order Header") + item_percent_margin_before = fields.Float('%Margin Before', compute='_compute_item_percent_margin_before', + help="Total % Margin excluding third party in Sales Order Header") initial_discount = fields.Float('Initial Discount') vendor_id = fields.Many2one( 'res.partner', string='Vendor', readonly=True, change_default=True, index=True, tracking=1, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]" - ) - vendor_md_id = fields.Many2one('res.partner', string='MD Vendor') + ) + vendor_md_id = fields.Many2one('res.partner', string='MD Vendor') purchase_price = fields.Float('Purchase', required=True, digits='Product Price', default=0.0) purchase_price_md = fields.Float('MD Purchase') - purchase_tax_id = fields.Many2one('account.tax', string='Tax', domain=['|', ('active', '=', False), ('active', '=', True)]) + purchase_tax_id = fields.Many2one('account.tax', string='Tax', + domain=['|', ('active', '=', False), ('active', '=', True)]) delivery_amt_line = fields.Float('DeliveryAmtLine', compute='compute_delivery_amt_line') fee_third_party_line = fields.Float('FeeThirdPartyLine', compute='compute_fee_third_party_line', default=0) line_no = fields.Integer('No', default=0, copy=False) @@ -28,13 +34,15 @@ class SaleOrderLine(models.Model): ('info_vendor', 'Info Vendor'), ('penggabungan', 'Penggabungan'), ], string='Note', help="Harap diisi jika ada keterangan tambahan dari Procurement, agar dapat dimonitoring") - note_procurement = fields.Char(string='Note Detail', help="Harap diisi jika ada keterangan tambahan dari Procurement, agar dapat dimonitoring") + note_procurement = fields.Char(string='Note Detail', + help="Harap diisi jika ada keterangan tambahan dari Procurement, agar dapat dimonitoring") vendor_subtotal = fields.Float(string='Vendor Subtotal', compute="_compute_vendor_subtotal") amount_voucher_disc = fields.Float(string='Voucher Discount') qty_reserved = fields.Float(string='Qty Reserved', compute='_compute_qty_reserved') - product_available_quantity = fields.Float(string='Qty pickup by user',) + product_available_quantity = fields.Float(string='Qty pickup by user', ) reserved_from = fields.Char(string='Reserved From', copy=False) - item_percent_margin_without_deduction = fields.Float('Margin Without Deduction', compute='_compute_item_margin_without_deduction') + item_percent_margin_without_deduction = fields.Float('Margin Without Deduction', + compute='_compute_item_margin_without_deduction') weight = fields.Float(string='Weight') md_vendor_id = fields.Many2one('res.partner', string='MD Vendor', readonly=True) margin_md = fields.Float(string='Margin MD') @@ -45,7 +53,8 @@ class SaleOrderLine(models.Model): outgoing_moves = self.env['stock.move'] incoming_moves = self.env['stock.move'] - for move in self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id): + for move in self.move_ids.filtered( + lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id): if move.location_dest_id.usage == "customer": if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund): outgoing_moves |= move @@ -80,7 +89,7 @@ class SaleOrderLine(models.Model): if not self.product_uom or not self.product_id: self.price_unit = 0.0 return - + self.price_unit = self.price_unit def _compute_qty_reserved(self): @@ -128,6 +137,29 @@ class SaleOrderLine(models.Model): else: line.item_percent_margin_without_deduction = 0 + def _compute_item_percent_margin_before(self): + for line in self: + if not line.product_id or line.product_id.type == 'service' \ + or line.price_unit <= 0 or line.product_uom_qty <= 0 \ + or not line.vendor_id: + line.item_percent_margin_before = 0 + continue + + sales_price = line.price_reduce_taxexcl * line.product_uom_qty + + purchase_price = line.purchase_price + if line.purchase_tax_id and line.purchase_tax_id.price_include: + purchase_price = line.purchase_price / 1.11 + + purchase_price = purchase_price * line.product_uom_qty + + margin_before = sales_price - purchase_price + + if sales_price > 0: + line.item_percent_margin_before = round((margin_before / sales_price), 2) * 100 + else: + line.item_percent_margin_before = 0 + def compute_item_margin(self): for line in self: if not line.product_id or line.product_id.type == 'service' \ @@ -141,8 +173,8 @@ class SaleOrderLine(models.Model): # minus with delivery if covered by indoteknik if line.order_id.shipping_cost_covered == 'indoteknik': sales_price -= line.delivery_amt_line - if line.order_id.fee_third_party > 0: - sales_price -= line.fee_third_party_line + # if line.order_id.fee_third_party > 0: + # sales_price -= line.fee_third_party_line purchase_price = line.purchase_price if line.purchase_tax_id.price_include: @@ -158,7 +190,7 @@ class SaleOrderLine(models.Model): line.item_percent_margin = 0 if not line.margin_md: - line.margin_md = line.item_percent_margin + line.margin_md = line.item_percent_margin def compute_item_before_margin(self): for line in self: @@ -169,7 +201,7 @@ class SaleOrderLine(models.Model): continue # calculate margin without tax sales_price = line.price_reduce_taxexcl * line.product_uom_qty - + purchase_price = line.purchase_price if line.purchase_tax_id.price_include: purchase_price = line.purchase_price / 1.11 @@ -183,7 +215,7 @@ class SaleOrderLine(models.Model): # TODO : need to change this logic @stephan if not self.product_id or self.product_id.type == 'service': return - elif self.product_id.categ_id.id == 34: # finish good / manufacturing only + elif self.product_id.categ_id.id == 34: # finish good / manufacturing only cost = self.product_id.standard_price self.purchase_price = cost elif self.product_id.x_manufacture.override_vendor_id: @@ -195,12 +227,12 @@ class SaleOrderLine(models.Model): self.purchase_price = price self.purchase_tax_id = taxes # else: - # purchase_price = self.env['purchase.pricelist'].search( - # [('vendor_id', '=', self.vendor_id.id), ('product_id', '=', self.product_id.id)], - # limit=1, order='count_trx_po desc, count_trx_po_vendor desc') - # price, taxes = self._get_valid_purchase_price(purchase_price) - # self.purchase_price = price - # self.purchase_tax_id = taxes + # purchase_price = self.env['purchase.pricelist'].search( + # [('vendor_id', '=', self.vendor_id.id), ('product_id', '=', self.product_id.id)], + # limit=1, order='count_trx_po desc, count_trx_po_vendor desc') + # price, taxes = self._get_valid_purchase_price(purchase_price) + # self.purchase_price = price + # self.purchase_tax_id = taxes # def _calculate_selling_price(self): # rec_purchase_price, rec_taxes, rec_vendor_id = self._get_purchase_price(self.product_id) @@ -260,7 +292,7 @@ class SaleOrderLine(models.Model): price = 0 taxes = 24 - vendor_id = '' + vendor_id = False human_last_update = purchase_price.human_last_update or datetime.min system_last_update = purchase_price.system_last_update or datetime.min @@ -271,18 +303,18 @@ class SaleOrderLine(models.Model): if delta_time > human_last_update: price = 0 taxes = 24 - vendor_id = '' + vendor_id = False if system_last_update > human_last_update: - #if purchase_price.taxes_system_id.type_tax_use == 'purchase': + # if purchase_price.taxes_system_id.type_tax_use == 'purchase': price = purchase_price.system_price taxes = purchase_price.taxes_system_id.id or 24 vendor_id = purchase_price.vendor_id.id if delta_time > system_last_update: price = 0 taxes = 24 - vendor_id = '' - + vendor_id = False + return price, taxes, vendor_id @api.onchange('product_id') @@ -302,11 +334,11 @@ class SaleOrderLine(models.Model): line.tax_id = line.order_id.sales_tax_id # price, taxes = line._get_valid_purchase_price(purchase_price) line.purchase_price = price - line.purchase_tax_id = taxes + line.purchase_tax_id = taxes attribute_values = line.product_id.product_template_attribute_value_ids.mapped('name') attribute_values_str = ', '.join(attribute_values) if attribute_values else '' - + line_name = ('[' + line.product_id.default_code + ']' if line.product_id.default_code else '') + ' ' + \ (line.product_id.name if line.product_id.name else '') + ' ' + \ ('(' + attribute_values_str + ')' if attribute_values_str else '') + ' ' + \ @@ -324,7 +356,7 @@ class SaleOrderLine(models.Model): price, taxes, vendor_id = self._get_purchase_price(line.product_id) line.vendor_md_id = vendor_id if vendor_id else None line.margin_md = line.item_percent_margin - line.purchase_price_md = price + line.purchase_price_md = price def compute_delivery_amt_line(self): for line in self: @@ -363,11 +395,15 @@ class SaleOrderLine(models.Model): fiscal_position=self.env.context.get('fiscal_position') ) - product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id) + product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, + uom=self.product_uom.id) price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule( self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) - new_list_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id) + new_list_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, + self.product_uom_qty, + self.product_uom, + self.order_id.pricelist_id.id) new_list_price = product.web_price if new_list_price != 0: @@ -390,8 +426,8 @@ class SaleOrderLine(models.Model): no_variant_attributes_price_extra = [ ptav.price_extra for ptav in self.product_no_variant_attribute_value_ids.filtered( lambda ptav: - ptav.price_extra and - ptav not in product.product_template_attribute_value_ids + ptav.price_extra and + ptav not in product.product_template_attribute_value_ids ) ] if no_variant_attributes_price_extra: @@ -401,10 +437,15 @@ class SaleOrderLine(models.Model): if self.order_id.pricelist_id.discount_policy == 'with_discount': return product.with_context(pricelist=self.order_id.pricelist_id.id, uom=self.product_uom.id).price - product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id) - - final_price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(product or self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) - base_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id) + product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, + uom=self.product_uom.id) + + final_price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule( + product or self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) + base_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, + self.product_uom_qty, + self.product_uom, + self.order_id.pricelist_id.id) base_price = product.web_price if currency != self.order_id.pricelist_id.currency_id: base_price = currency._convert( @@ -413,7 +454,7 @@ class SaleOrderLine(models.Model): # negative discounts (= surcharge) are included in the display price return max(base_price, final_price) - + def validate_line(self): for line in self: if line.product_id.id in [385544, 224484, 417724]: diff --git a/indoteknik_custom/models/sale_orders_multi_update.py b/indoteknik_custom/models/sale_orders_multi_update.py index 95cfde21..962f60b5 100644 --- a/indoteknik_custom/models/sale_orders_multi_update.py +++ b/indoteknik_custom/models/sale_orders_multi_update.py @@ -11,6 +11,13 @@ class SaleOrdersMultiUpdate(models.TransientModel): sale_ids = self._context['sale_ids'] sales = self.env['sale.order'].browse(sale_ids) sales.action_multi_update_invoice_status() + + for sale in sales: + sale.message_post( + body="Sales Order has been marked as Completed", + message_type="comment" + ) + return { 'type': 'ir.actions.client', 'tag': 'display_notification', diff --git a/indoteknik_custom/models/shipment_group.py b/indoteknik_custom/models/shipment_group.py index 87d222a6..4969c35a 100644 --- a/indoteknik_custom/models/shipment_group.py +++ b/indoteknik_custom/models/shipment_group.py @@ -19,11 +19,13 @@ class ShipmentGroup(models.Model): def sync_api_shipping(self): for rec in self.shipment_line: - if rec.shipment_id.carrier_id == 173: - rec.picking_id.action_get_kgx_pod() - - if rec.shipment_id.carrier_id == 151: - rec.picking_id.track_envio_shipment() + picking_names = [lines.picking_id.name for lines in self.shipment_line] + if rec.shipment_id.carrier_id.id == 173: + rec.picking_id.action_get_kgx_pod( + shipment=f"{self.number}" + ) + elif rec.shipment_id.carrier_id.id == 151: + rec.picking_id.track_envio_shipment(shipment=f"{self.number}") @api.depends('shipment_line.total_colly') def _compute_total_colly_line(self): diff --git a/indoteknik_custom/models/stock_picking.py b/indoteknik_custom/models/stock_picking.py index 0fcb7ca1..3e152f10 100644 --- a/indoteknik_custom/models/stock_picking.py +++ b/indoteknik_custom/models/stock_picking.py @@ -19,15 +19,19 @@ import re _logger = logging.getLogger(__name__) _biteship_url = "https://api.biteship.com/v1" -_biteship_api_key = "biteship_test.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTcyOTQ5ODAwMX0.L6C73couP4-cgVEfhKI2g7eMCMo3YOFSRZhS-KSuHNA" +biteship_api_key = "biteship_live.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiaW5kb3Rla25payIsInVzZXJJZCI6IjY3MTViYTJkYzVkMjdkMDAxMjRjODk2MiIsImlhdCI6MTc0MTE1NTU4M30.pbFCai9QJv8iWhgdosf8ScVmEeP3e5blrn33CHe7Hgo" -# _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' _order = 'final_seq ASC' + tukar_guling_id = fields.Many2one( + 'tukar.guling', + string='Tukar Guling Reference' + ) konfirm_koli_lines = fields.One2many('konfirm.koli', 'picking_id', string='Konfirm Koli', auto_join=True, copy=False) scan_koli_lines = fields.One2many('scan.koli', 'picking_id', string='Scan Koli', auto_join=True, copy=False) @@ -121,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([ @@ -170,6 +174,10 @@ class StockPicking(models.Model): area_name = fields.Char(string="Area", compute="_compute_area_name") + # def _get_biteship_api_key(self): + # # return self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_test') + # return self.env['ir.config_parameter'].sudo().get_param('biteship.api_key_live') + @api.depends('real_shipping_id.kecamatan_id', 'real_shipping_id.kota_id') def _compute_area_name(self): for record in self: @@ -269,14 +277,27 @@ 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") - # estimated_ready_ship_date = fields.Datetime(string='ET Ready to Ship', copy=False, related='sale_id.estimated_ready_ship_date') - # countdown_hours = fields.Float(string='Countdown in Hours', compute='_callculate_sequance', default=False, store=False, compute_sudo=False) - # countdown_ready_to_ship = fields.Char(string='Countdown Ready to Ship', compute='_callculate_sequance', store=False, compute_sudo=False) + 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_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', 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') @@ -288,8 +309,8 @@ class StockPicking(models.Model): self.ensure_one() if not self.name or not self.origin: return False - return f"{self.name} {self.origin}" - + return f"{self.name}" + def _download_pod_photo(self, url): """Mengunduh foto POD dari URL""" try: @@ -298,48 +319,53 @@ 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: from datetime import datetime - # Hilangkan timezone jika ada masalah parsing + + if not dt_str: + return False + if '+' in dt_str: dt_str = dt_str.split('+')[0] + return datetime.strptime(dt_str, '%Y-%m-%dT%H:%M:%S') except ValueError: return False - - def action_get_kgx_pod(self): + + + def action_get_kgx_pod(self, shipment=False): self.ensure_one() - - awb_number = self._get_kgx_awb_number() + + 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)}") @@ -529,13 +555,7 @@ class StockPicking(models.Model): record.kgx_pod_photo = "No image available." def action_fetch_lalamove_order(self): - pickings = self.env['stock.picking'].search([ - ('picking_type_code', '=', 'outgoing'), - ('state', '=', 'done'), - ('carrier_id', '=', 9), - ('lalamove_order_id', '!=', False) - ]) - for picking in pickings: + for picking in self: try: order_id = picking.lalamove_order_id apikey = self.env['ir.config_parameter'].sudo().get_param('lalamove.apikey') @@ -584,6 +604,7 @@ class StockPicking(models.Model): self.lalamove_phone = phone self.lalamove_status = pod.get("status") self.lalamove_delivered_at = delivered_at_dt + self.driver_arrival_date = delivered_at_dt return data raise UserError("No delivered data found in Lalamove response.") @@ -619,18 +640,19 @@ class StockPicking(models.Model): except ValueError: raise UserError(f"Format waktu tidak sesuai: {date_str}") - def track_envio_shipment(self): + def track_envio_shipment(self, shipment=False): pickings = self.env['stock.picking'].search([ ('picking_type_code', '=', 'outgoing'), ('state', '=', 'done'), ('carrier_id', '=', 151) ]) - for picking in pickings: + for picking in self: + name = shipment or picking.name if not picking.name: raise UserError("Name pada stock.picking tidak ditemukan.") # API URL dan headers - url = f"https://api.envio.co.id/v1/tracking/distribution?code={picking.name}" + url = f"https://api.envio.co.id/v1/tracking/distribution?code={name}" headers = { 'Authorization': 'Bearer JZ0Seh6qpYJAC3CJHdhF7sPqv8B/uSSfZe1VX5BL?vPYdo', 'Content-Type': 'application/json', @@ -686,46 +708,56 @@ class StockPicking(models.Model): raise UserError(f"Kesalahan tidak terduga: {str(e)}") def action_send_to_biteship(self): - if self.biteship_tracking_id: raise UserError(f"Order ini sudah dikirim ke Biteship. Dengan Tracking Id: {self.biteship_tracking_id}") - # Mencari data sale.order.line berdasarkan sale_id - products = self.env['sale.order.line'].search([('order_id', '=', self.sale_id.id)]) - - # Fungsi untuk membangun items_data dari order lines - def build_items_data(lines): - return [{ - "name": line.product_id.name, - "description": line.name, - "value": line.price_unit, - "quantity": line.product_uom_qty, - "weight": line.weight - } for line in lines] - - # Items untuk pengiriman standard - items_data_standard = build_items_data(products) + if self.sale_id.select_shipping_option == 'custom': + 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", + "instant_bike", "motorcycle", "mpv", "van", "truck", + "cdd_bak", "cdd_box", "engkel_box", "engkel_bak" + ] + + # ✅ Ambil item dari move_line_ids_with_package (qty_done > 0) + items = [] + for ml in self.move_line_ids_without_package: + if ml.qty_done <= 0: + continue - # Items untuk pengiriman instant, mengambil product_id dari move_line_ids_without_package - items_data_instant = [] - for move_line in self.move_line_ids_without_package: - # Mencari baris di sale.order.line berdasarkan product_id dari move_line - order_line = self.env['sale.order.line'].search([ + product = ml.product_id + weight = product.weight or 0.1 # default minimal + line = ml.move_id.sale_line_id or self.env['sale.order.line'].search([ ('order_id', '=', self.sale_id.id), - ('product_id', '=', move_line.product_id.id) + ('product_id', '=', ml.product_id.id) ], limit=1) - if order_line: - items_data_instant.append({ - "name": order_line.product_id.name, - "description": order_line.name, - "value": order_line.price_unit, - "quantity": move_line.qty_done, - "weight": order_line.weight - }) + value = line.price_unit if line else 0 + description = line.name if line else product.name + + items.append({ + "name": product.name, + "description": description, + "value": value, + "quantity": ml.qty_done, + "weight": int(weight * 1000), + }) + + if not items: + raise UserError("Pengiriman tidak dapat dilakukan karena tidak ada barang yang divalidasi (qty_done = 0).") + + shipping_partner = self.real_shipping_id + courier_service_code = self.sale_id.delivery_service_type or "reg" payload = { - "reference_id ": self.sale_id.name, + "origin_coordinate": { + "latitude": -6.3031123, + "longitude": 106.7794934999 + }, + "reference_id": self.name, "shipper_contact_name": self.carrier_id.pic_name or '', "shipper_contact_phone": self.carrier_id.pic_phone or '', "shipper_organization": self.carrier_id.name, @@ -733,41 +765,39 @@ class StockPicking(models.Model): "origin_contact_phone": "081717181922", "origin_address": "Jl. Bandengan Utara Komp A & BRT. Penjaringan, Kec. Penjaringan, Jakarta (BELAKANG INDOMARET) KOTA JAKARTA UTARA PENJARINGAN", "origin_postal_code": 14440, - "destination_contact_name": self.real_shipping_id.name, - "destination_contact_phone": self.real_shipping_id.phone or self.real_shipping_id.mobile, - "destination_address": self.real_shipping_id.street, - "destination_postal_code": self.real_shipping_id.zip, + "destination_contact_name": shipping_partner.name, + "destination_contact_phone": shipping_partner.phone or shipping_partner.mobile, + "destination_address": shipping_partner.street, + "destination_postal_code": shipping_partner.zip, "origin_note": "BELAKANG INDOMARET", - "courier_type": self.sale_id.delivery_service_type or "reg", + "destination_note": f"SO: {self.sale_id.name}", + "order_note": f"SO: {self.sale_id.name}", + "courier_type": courier_service_code, "courier_company": self.carrier_id.name.lower(), "delivery_type": "now", - "destination_postal_code": self.real_shipping_id.zip, - "items": items_data_standard + "items": items } - # Cek jika pengiriman instant atau same_day - if self.sale_id.delivery_service_type and ( - "instant" in self.sale_id.delivery_service_type or "same_day" in self.sale_id.delivery_service_type): - payload.update({ - "origin_coordinate": { - "latitude": -6.3031123, - "longitude": 106.7794934999 - }, - "destination_coordinate": { - "latitude": self.real_shipping_id.latitude, - "longitude": self.real_shipping_id.longtitude, - }, - "items": items_data_instant - }) + if is_courier_need_coordinates(courier_service_code): + if not shipping_partner.latitude or not shipping_partner.longtitude: + raise UserError("Alamat tujuan tidak memiliki koordinat (latitude/longitude).") + + payload["destination_coordinate"] = { + "latitude": shipping_partner.latitude, + "longitude": shipping_partner.longtitude, + } - api_key = _biteship_api_key + _logger.info(f"Payload untuk Biteship: {payload}") + + # Kirim ke Biteship + api_key = biteship_api_key headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json" } - # Kirim request ke Biteship response = requests.post(_biteship_url + '/orders', headers=headers, json=payload) + _logger.info(f"Response dari Biteship: {response.text}") if response.status_code == 200: data = response.json() @@ -775,17 +805,27 @@ class StockPicking(models.Model): self.biteship_id = data.get("id", "") self.biteship_tracking_id = data.get("courier", {}).get("tracking_id", "") self.biteship_waybill_id = data.get("courier", {}).get("waybill_id", "") - self.delivery_tracking_no = data.get("courier", {}).get("waybill_id", "") - - waybill_id = data.get("courier", {}).get("waybill_id", "") + self.delivery_tracking_no = self.biteship_waybill_id + + waybill_id = self.biteship_waybill_id + + 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}", + message_type="comment" + ) message = f"✅ Berhasil Order ke Biteship! Resi: {waybill_id}" if waybill_id else "⚠️ Order berhasil, tetapi tidak ada nomor resi." return { 'effect': { - 'fadeout': 'slow', # Efek menghilang perlahan - 'message': message, # Pesan sukses - 'type': 'rainbow_man', # Efek animasi lucu Odoo + 'fadeout': 'slow', + 'message': message, + 'type': 'rainbow_man', } } else: @@ -917,6 +957,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( @@ -1055,38 +1098,40 @@ class StockPicking(models.Model): self.approval_receipt_status = 'pengajuan1' def ask_return_approval(self): - for pick in self: - if self.env.user.is_accounting: - pick.approval_return_status = 'approved' - continue - else: - pick.approval_return_status = 'pengajuan1' - - action = self.env['ir.actions.act_window']._for_xml_id('indoteknik_custom.action_stock_return_note_wizard') - - if self.picking_type_code == 'outgoing': - if self.env.user.id in [3988, 3401, 20] or ( - self.env.user.has_group( - 'indoteknik_custom.group_role_purchasing') and 'Return of' in self.origin - ): - action['context'] = {'picking_ids': [x.id for x in self]} - return action - elif not self.env.user.has_group( - 'indoteknik_custom.group_role_purchasing') and 'Return of' in self.origin: - raise UserError('Harus Purchasing yang Ask Return') - else: - raise UserError('Harus Sales Admin yang Ask Return') - - elif self.picking_type_code == 'incoming': - if self.env.user.has_group('indoteknik_custom.group_role_purchasing') or ( - self.env.user.id in [3988, 3401, 20] and 'Return of' in self.origin - ): - action['context'] = {'picking_ids': [x.id for x in self]} - return action - elif not self.env.user.id in [3988, 3401, 20] and 'Return of' in self.origin: - raise UserError('Harus Sales Admin yang Ask Return') - else: - raise UserError('Harus Purchasing yang Ask Return') + pass + raise UserError("Bisa langsung Validate") + # for pick in self: + # if self.env.user.is_accounting: + # pick.approval_return_status = 'approved' + # continue + # else: + # pick.approval_return_status = 'pengajuan1' + # + # action = self.env['ir.actions.act_window']._for_xml_id('indoteknik_custom.action_stock_return_note_wizard') + # + # if self.picking_type_code == 'outgoing': + # if self.env.user.id in [3988, 3401, 20] or ( + # self.env.user.has_group( + # 'indoteknik_custom.group_role_purchasing') and 'Return of' in self.origin + # ): + # action['context'] = {'picking_ids': [x.id for x in self]} + # return action + # elif not self.env.user.has_group( + # 'indoteknik_custom.group_role_purchasing') and 'Return of' in self.origin: + # raise UserError('Harus Purchasing yang Ask Return') + # else: + # raise UserError('Harus Sales Admin yang Ask Return') + # + # elif self.picking_type_code == 'incoming': + # if self.env.user.has_group('indoteknik_custom.group_role_purchasing') or ( + # self.env.user.id in [3988, 3401, 20] and 'Return of' in self.origin + # ): + # action['context'] = {'picking_ids': [x.id for x in self]} + # return action + # elif not self.env.user.id in [3988, 3401, 20] and 'Return of' in self.origin: + # raise UserError('Harus Sales Admin yang Ask Return') + # else: + # raise UserError('Harus Purchasing yang Ask Return') def calculate_line_no(self): @@ -1181,6 +1226,10 @@ class StockPicking(models.Model): def button_validate(self): self.check_invoice_date() + _logger.info("Kode Picking: %s", self.picking_type_id.code) + _logger.info("Group ID: %s", self.group_id) + _logger.info("Group ID ID: %s", self.group_id.id if self.group_id else None) + _logger.info("Is Internal Use: %s", self.is_internal_use) threshold_datetime = waktu(2025, 4, 11, 6, 26) group_id = self.env.ref('indoteknik_custom.group_role_merchandiser').id users_in_group = self.env['res.users'].search([('groups_id', 'in', [group_id])]) @@ -1269,6 +1318,19 @@ class StockPicking(models.Model): current_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') self.date_reserved = current_time + # Validate Qty Demand Can't higher than Qty Product + for move_line in self.move_line_ids_without_package: + purchase_line = move_line.move_id.purchase_line_id + if purchase_line: + if purchase_line.product_uom_qty < move_line.product_uom_qty: + raise UserError( + _("Quantity demand (%s) tidak bisa lebih besar dari qty product (%s) untuk produk %s") % ( + move_line.product_uom_qty, + purchase_line.product_uom_qty, + move_line.product_id.display_name + ) + ) + self.validation_minus_onhand_quantity() self.responsible = self.env.user.id # self.send_koli_to_so() @@ -1280,7 +1342,6 @@ class StockPicking(models.Model): self.final_seq = 0 self.set_picking_code_out() self.send_koli_to_so() - if (self.state_reserve == 'done' and self.picking_type_code == 'internal' and 'BU/PICK/' in self.name and self.linked_manual_bu_out): if not self.linked_manual_bu_out.date_reserved: @@ -1317,26 +1378,40 @@ class StockPicking(models.Model): 'target': 'new', } self.send_mail_bills() + 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([ + ('name', '=', self.group_id.name) + ]) + + for line in po.order_sales_match_line: + if not line.bu_pick: + continue + line.bu_pick.action_assign() + def check_invoice_date(self): for picking in self: if picking.picking_type_code != 'outgoing' or 'BU/OUT/' not in picking.name or picking.partner_id.id == 96868: 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!") picking_date = fields.Date.to_date(picking.date_doc_kirim) invoice_date = fields.Date.to_date(invoice.invoice_date) - if picking_date != invoice_date and picking.update_date_doc_kirim_add: + if picking_date != invoice_date and picking.update_date_doc_kirim_add and not picking.so_lama: raise UserError("Tanggal Kirim (%s) tidak sesuai dengan Tanggal Invoice (%s)!" % ( picking_date.strftime('%d-%m-%Y'), invoice_date.strftime('%d-%m-%Y') @@ -1515,25 +1590,25 @@ class StockPicking(models.Model): new_picking.state_packing = 'packing_done' self._use_faktur(vals) self.sync_sale_line(vals) - for picking in self: - # Periksa apakah kondisi terpenuhi saat data diubah - if (vals.get('picking_type_code', picking.picking_type_code) == 'incoming' and - vals.get('location_dest_id', picking.location_dest_id.id) == 58): - if 'name' in vals or picking.name.startswith('BU/IN/'): - name_to_modify = vals.get('name', picking.name) - if name_to_modify.startswith('BU/IN/'): - vals['name'] = name_to_modify.replace('BU/IN/', 'BU/INPUT/', 1) - - if (vals.get('picking_type_code', picking.picking_type_code) == 'internal' and - vals.get('location_id', picking.location_id.id) == 58): - name_to_modify = vals.get('name', picking.name) - if name_to_modify.startswith('BU/INT'): - new_name = name_to_modify.replace('BU/INT', 'BU/IN', 1) - # Periksa apakah nama sudah ada - if self.env['stock.picking'].search_count( - [('name', '=', new_name), ('company_id', '=', picking.company_id.id)]) > 0: - new_name = f"{new_name}-DUP" - vals['name'] = new_name + # for picking in self: + # # Periksa apakah kondisi terpenuhi saat data diubah + # if (vals.get('picking_type_code', picking.picking_type_code) == 'incoming' and + # vals.get('location_dest_id', picking.location_dest_id.id) == 58): + # if 'name' in vals or picking.name.startswith('BU/IN/'): + # name_to_modify = vals.get('name', picking.name) + # if name_to_modify.startswith('BU/IN/'): + # vals['name'] = name_to_modify.replace('BU/IN/', 'BU/INPUT/', 1) + + # if (vals.get('picking_type_code', picking.picking_type_code) == 'internal' and + # vals.get('location_id', picking.location_id.id) == 58): + # name_to_modify = vals.get('name', picking.name) + # if name_to_modify.startswith('BU/INT'): + # new_name = name_to_modify.replace('BU/INT', 'BU/IN', 1) + # # Periksa apakah nama sudah ada + # if self.env['stock.picking'].search_count( + # [('name', '=', new_name), ('company_id', '=', picking.company_id.id)]) > 0: + # new_name = f"{new_name}-DUP" + # vals['name'] = new_name return super(StockPicking, self).write(vals) def _use_faktur(self, vals): @@ -1614,27 +1689,51 @@ class StockPicking(models.Model): 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 = [] + for move_line in self.move_line_ids_without_package: + if move_line.qty_done > 0: + product_shipped.append({ + 'name': move_line.product_id.name, + 'qty': move_line.qty_done + }) + response = { 'delivery_order': { 'name': self.name, - 'carrier': self.carrier_id.name or '', - 'service': order.delivery_service_type or '', + 'carrier': self.carrier_id.name 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': False, 'status': self.shipping_status, - 'waybill_number': self.delivery_tracking_no or '', + 'waybill_number': self.delivery_tracking_no or '-', 'delivery_status': None, 'eta': self.generate_eta_delivery(), 'is_biteship': True if self.biteship_id else False, - 'manifests': self.get_manifests() + 'manifests': self.get_manifests(), + 'is_delay': True if sale_order_delay and sale_order_delay.status == 'delayed' else False, + 'products': product_shipped } if self.biteship_id: histori = self.get_manifest_biteship() - eta_start = order.date_order + timedelta(days=order.estimated_arrival_days_start) - eta_end = order.date_order + timedelta(days=order.estimated_arrival_days) + day_start = order.estimated_arrival_days_start + day_end = order.estimated_arrival_days + if sale_order_delay: + if sale_order_delay.status == 'delayed': + day_start = day_start + sale_order_delay.days_delayed + day_end = day_end + sale_order_delay.days_delayed + 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')}" response['eta'] = formatted_eta response['manifests'] = histori.get("manifests", []) @@ -1656,7 +1755,7 @@ class StockPicking(models.Model): return response def get_manifest_biteship(self): - api_key = _biteship_api_key + api_key = biteship_api_key headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json" @@ -1669,16 +1768,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("name", ""), - 'on_hold': 'Pesanan ditahan sementara karena masalah pengiriman', - 'dropping_off': 'Kurir sudah ditugaskan dan pesanan akan segera diantar ke pembeli', - 'delivered': 'Pesanan telah sampai dan diterima oleh ' + result.get("destination", {}).get( - "contact_name", "") - } + # 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", "") @@ -1687,7 +1785,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 { @@ -1695,19 +1793,205 @@ class StockPicking(models.Model): "delivered": status } - return manifests + return { + "manifests": [], + "delivered": False + } except Exception as e: _logger.error(f"Error fetching Biteship order for picking {self.id}: {str(e)}") return {'error': str(e)} - def _convert_to_local_time(self, iso_date): + # 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', + } + + 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', + } + + return description_map.get(status, f"Status '{status}' diterima dari Biteship") + + def log_biteship_event_from_webhook(self, status, timestamp, description, extra_data=None): + self.ensure_one() + updated_fields = {} + try: - dt_with_tz = waktu.fromisoformat(iso_date) - utc_dt = dt_with_tz.astimezone(pytz.utc) + 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 + ) - local_tz = pytz.timezone("Asia/Jakarta") - local_dt = utc_dt.astimezone(local_tz) + # 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() + self.env.cr.execute(""" + SELECT 1 FROM mail_message + WHERE model = %s AND res_id = %s + AND subtype_id IS NOT NULL + AND body ILIKE %s + LIMIT 1 + """, (self._name, self.id, f"%{log_line}%")) + return self.env.cr.fetchone() is not None + + # Untuk internal Odoo (mengembalikan naive UTC datetime untuk disimpan ke DB) + def _convert_to_utc_datetime(self, iso_date): + try: + if isinstance(iso_date, str): + waktu = parser.parse(iso_date) + else: + waktu = iso_date + if waktu.tzinfo is None: + waktu = waktu.replace(tzinfo=pytz.utc) + utc_dt = waktu.astimezone(pytz.utc).replace(tzinfo=None) + return utc_dt + except Exception as e: + _logger.warning(f"[Biteship] Gagal konversi waktu UTC: {e}") + return False + + # Untuk tampilan di API atau kebutuhan web (mengembalikan string waktu lokal) + def _convert_to_local_time(self, iso_date): + try: + if isinstance(iso_date, str): + waktu = parser.parse(iso_date) + else: + waktu = iso_date + if waktu.tzinfo is None: + waktu = waktu.replace(tzinfo=pytz.utc) + local_tz = pytz.timezone("Asia/Jakarta") + local_dt = waktu.astimezone(local_tz) return local_dt.strftime("%Y-%m-%d %H:%M:%S") except Exception as e: return str(e) @@ -1719,29 +2003,39 @@ 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") def generate_eta_delivery(self): current_date = datetime.datetime.now() - prepare_days = 3 - start_date = self.driver_departure_date or self.create_date - - ead = self.sale_id.estimated_arrival_days or 0 - if not self.driver_departure_date: - ead += prepare_days - - ead_datetime = datetime.timedelta(days=ead) - fastest_eta = start_date + ead_datetime - if not self.driver_departure_date and fastest_eta < current_date: - fastest_eta = current_date + ead_datetime - - longest_days = 3 - longest_eta = fastest_eta + datetime.timedelta(days=longest_days) + days_start = self.sale_id.estimated_arrival_days_start or self.sale_id.estimated_arrival_days + 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) + if sale_order_delay: + if sale_order_delay.status == 'delayed': + 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) + + longest_eta = end_date + datetime.timedelta(days=add_day_end) format_time = '%d %b %Y' format_time_fastest = '%d %b' if fastest_eta.year == longest_eta.year else format_time @@ -2229,6 +2523,8 @@ class KonfirmKoli(models.Model): copy=False, ) pick_id = fields.Many2one('stock.picking', string='Pick') + product_id = fields.Many2one('product.product', string='Product') + qty_done = fields.Float(string='Qty Done') @api.constrains('pick_id') def _check_duplicate_pick_id(self): @@ -2254,4 +2550,4 @@ class WarningModalWizard(models.TransientModel): def action_continue(self): if self.picking_id: return self.picking_id.with_context(skip_koli_check=True).button_validate() - return {'type': 'ir.actions.act_window_close'} + return {'type': 'ir.actions.act_window_close'}
\ No newline at end of file diff --git a/indoteknik_custom/models/stock_picking_return.py b/indoteknik_custom/models/stock_picking_return.py index a683d80e..1fc8d088 100644 --- a/indoteknik_custom/models/stock_picking_return.py +++ b/indoteknik_custom/models/stock_picking_return.py @@ -1,38 +1,154 @@ -from odoo import _, api, fields, models from odoo.exceptions import UserError from odoo.tools.float_utils import float_round +from odoo import models, fields, api, _ +import logging +_logger = logging.getLogger(__name__) class ReturnPicking(models.TransientModel): _inherit = 'stock.return.picking' - @api.model - def default_get(self, fields): - res = super(ReturnPicking, self).default_get(fields) - - stock_picking = self.env['stock.picking'].search([ - ('id', '=', res['picking_id']), - ]) - - # sale_id = stock_picking.group_id.sale_id - if not stock_picking.approval_return_status == 'approved': - raise UserError('Harus Approval Accounting AR untuk melakukan Retur') - - # purchase = self.env['purchase.order'].search([ - # ('name', '=', stock_picking.group_id.name), - # ]) - # if not stock_picking.approval_return_status == 'approved' and purchase.invoice_ids: - # raise UserError('Harus Approval Accounting AP untuk melakukan Retur') - - return res - + # return_type = fields.Selection([ + # ('revisi_so', 'Revisi SO'), + # ('tukar_guling', 'Tukar Guling') + # ], string='Jenis Retur', default='revisi_so') + + + def create_returns(self): + picking = self.picking_id + # guling = self.env['tukar.guling'] + # if guling._is_already_returned(picking): + # raise UserError("BU ini sudah pernah diretur oleh dokumen lain.") + # if self._is_already_returned(picking): + # raise UserError("BU ini sudah pernah diretur oleh dokumen lain.") + # if picking.picking_type_id.id == 30 and picking.linked_manual_bu_out.state == 'done': + # raise UserError("❌ BU/PICK tidak dapat di retur karena BU/OUT Sudah Done") + + if self._context.get('from_ui', True): + return self._redirect_to_tukar_guling() + return super(ReturnPicking, self).create_returns() + + def _redirect_to_tukar_guling(self): + """Redirect ke Tukar Guling SO atau PO form dengan pre-filled data""" + self.ensure_one() + picking = self.picking_id + + # Ambil lines valid + valid_lines = [] + self.env.cr.execute("SELECT id FROM stock_return_picking_line WHERE wizard_id = %s", (self.id,)) + line_ids = [row[0] for row in self.env.cr.fetchall()] + if line_ids: + existing_lines = self.env['stock.return.picking.line'].sudo().browse(line_ids) + for line in existing_lines: + if line.exists() and line.quantity > 0: + valid_lines.append(line) + + if not valid_lines: + for line in self.product_return_moves: + if hasattr(line, 'quantity') and line.quantity > 0: + valid_lines.append(line) + + if not valid_lines: + raise UserError(_("Tidak ada produk yang bisa diretur. Pastikan ada produk dengan quantity > 0.")) + + # Siapkan context + context = { + 'default_operations': picking.id, + 'default_date': fields.Datetime.now(), + 'default_state': 'draft', + 'default_notes': _('Retur dari %s') % picking.name, + 'from_return_picking': True, + } + if picking.origin: + context['default_origin'] = picking.origin + if picking.partner_id: + context['default_partner_id'] = picking.partner_id.id + if hasattr(picking, 'real_shipping_id') and picking.real_shipping_id: + context['default_real_shipping_id'] = picking.real_shipping_id.id + elif picking.partner_id: + context['default_real_shipping_id'] = picking.partner_id.id + + # Siapkan product lines + line_vals = [] + sequence = 10 + for line in valid_lines: + quantity = getattr(line, 'quantity', 0) + if quantity <= 0: + continue + product = getattr(line, 'product_id', None) + if not product: + continue + line_vals.append((0, 0, { + 'sequence': sequence, + 'product_id': product.id, + 'product_uom_qty': quantity, + 'product_uom': product.uom_id.id, + 'name': product.display_name, + })) + sequence += 10 + if line_vals: + context['default_line_ids'] = line_vals + + if picking.picking_type_id.id == 29: + mapping_koli_vals = [] + sequence = 10 + returned_product_ids = set() + + # Ambil move lines dari BU/PICK + for move_line in picking.move_line_ids_without_package: + # Cek apakah produk ini ada di daftar retur dan qty_done > 0 + if move_line.product_id.id in returned_product_ids and move_line.qty_done > 0: + mapping_koli_vals.append((0, 0, { + 'sequence': sequence, + 'pick_id': picking.id, # ID BU/PICK itu sendiri + 'product_id': move_line.product_id.id, + 'qty_done': move_line.qty_done, + 'qty_return': move_line.qty_done, + })) + sequence += 10 + + if mapping_koli_vals: + context['default_mapping_koli_ids'] = mapping_koli_vals + + if picking.purchase_id or 'PO' in picking.origin: + _logger.info("Redirect ke Tukar Guling PO via purchase_id / origin") + return { + 'name': _('Tukar Guling PO'), + 'type': 'ir.actions.act_window', + 'res_model': 'tukar.guling.po', + 'view_mode': 'form', + 'target': 'current', + 'context': context, + } + else: + _logger.info("This picking is NOT from a PO, fallback to SO.") + return { + 'name': _('Tukar Guling SO'), + 'type': 'ir.actions.act_window', + 'res_model': 'tukar.guling', + 'view_mode': 'form', + 'target': 'current', + 'context': context, + } + + class ReturnPickingLine(models.TransientModel): _inherit = 'stock.return.picking.line' @api.onchange('quantity') def _onchange_quantity(self): + """Validate quantity against done quantity""" for rec in self: - qty_done = rec.move_id.quantity_done + if rec.move_id and rec.quantity > 0: + # Get quantity done from the move + qty_done = rec.move_id.quantity_done + + # If quantity_done is 0, use product_uom_qty as fallback + if qty_done == 0: + qty_done = rec.move_id.product_uom_qty - if rec.quantity > qty_done: - raise UserError(f"Quantity yang Anda masukkan tidak boleh melebihi quantity done yaitu: {qty_done}")
\ No newline at end of file + if rec.quantity > qty_done: + raise UserError( + _("Quantity yang Anda masukkan (%.2f) tidak boleh melebihi quantity done yaitu: %.2f untuk produk %s") + % (rec.quantity, qty_done, rec.product_id.name) + ) diff --git a/indoteknik_custom/models/tukar_guling.py b/indoteknik_custom/models/tukar_guling.py new file mode 100644 index 00000000..7253afb7 --- /dev/null +++ b/indoteknik_custom/models/tukar_guling.py @@ -0,0 +1,843 @@ +from odoo import models, fields, api, _ +from odoo.exceptions import UserError, ValidationError +import logging +from datetime import datetime + +_logger = logging.getLogger(__name__) + +#TODO +# 1. tracking status dokumen BU [X] +# 2. ganti nama dokumen +# 3. Tracking ketika create dokumen [X] +# 4. Tracking ketika ganti field operations, date approval (sales, finance, logistic) [X] +# 5. Ganti proses approval ke Sales, Finance, Logistic [X] +# 6. Make sure bu pick dan out tidak bisa diedit ketika ort dan srt blm done +# 7. change approval + +class TukarGuling(models.Model): + _name = 'tukar.guling' + _description = 'Tukar Guling' + _order = 'date desc, id desc' + _rec_name = 'name' + _inherit = ['mail.thread', 'mail.activity.mixin'] + + partner_id = fields.Many2one('res.partner', string='Customer', readonly=True) + origin = fields.Char(string='Origin SO') + if_so = fields.Boolean('Is SO', default=True) + if_po = fields.Boolean('Is PO', default=False) + real_shipping_id = fields.Many2one('res.partner', string='Shipping Address') + picking_ids = fields.One2many( + 'stock.picking', + 'tukar_guling_id', + string='Transfers' + ) + # origin_so = fields.Many2one('sale.order', string='Origin SO') + name = fields.Char('Number', required=True, copy=False, readonly=True, default='New') + date = fields.Datetime('Date', default=fields.Datetime.now, required=True) + operations = fields.Many2one( + 'stock.picking', + string='Operations', + domain=[ + '|', + # BU/OUT + '&', + ('picking_type_id.id', '=', 29), + ('state', '=', 'done'), + '&', + '&', + ('picking_type_id.id', '=', 30), + ('state', '=', 'done'), + ('linked_manual_bu_out', '!=', 'done'), + ], + help='Nomor BU/OUT atau BU/PICK', tracking=3, + required=True + ) + ba_num = fields.Text('Nomor BA') + notes = fields.Text('Notes') + return_type = fields.Selection(String='Return Type', selection=[ + ('tukar_guling', 'Tukar Guling'), # -> barang yang sama + ('revisi_so', 'Revisi SO')], required=True, tracking=3) + state = fields.Selection(string='Status', selection=[ + ('draft', 'Draft'), + ('approval_sales', ' Approval Sales'), + ('approval_finance', 'Approval Finance'), + ('approval_logistic', 'Approval Logistic'), + ('done', 'Done'), + ('cancel', 'Canceled') + ], default='draft', tracking=True, required=True) + + line_ids = fields.One2many('tukar.guling.line', 'tukar_guling_id', string='Product Lines') + mapping_koli_ids = fields.One2many('tukar.guling.mapping.koli', 'tukar_guling_id', string='Mapping Koli') + date_finance = fields.Datetime('Approved Date Finance', tracking=3, readonly=True) + date_sales = fields.Datetime('Approved Date Sales', tracking=3, readonly=True) + date_logistic = fields.Datetime('Approved Date Logistic', tracking=3, readonly=True) + + # @api.onchange('operations') + # def get_partner_id(self): + # if self.operations and self.operations.partner_id and self.operations.partner_id.name: + # self.partner_id == self.operations.partner_id.name + + def _check_mapping_koli(self): + for record in self: + if record.operations.picking_type_id.id == 29: # Only for BU/OUT + if not record.mapping_koli_ids: + raise UserError("❌ Mapping Koli belum diisi") + + # Calculate totals + total_mapping_qty = sum(int(mapping.qty_return) for mapping in record.mapping_koli_ids) + total_line_qty = sum(int(line.product_uom_qty) for line in record.line_ids) + + if total_mapping_qty != total_line_qty: + raise UserError( + "❌ Total quantity return di mapping koli (%d) tidak sama dengan quantity retur product lines (%d)" % + (total_mapping_qty, total_line_qty) + ) + else: + _logger.info("✅ Qty mapping koli sesuai dengan product lines") + + @api.onchange('operations') + def _onchange_operations(self): + """Auto-populate lines ketika operations dipilih""" + if self.operations.picking_type_id.id not in [29,30]: + raise UserError("❌ Picking type harus BU/OUT atau BU/PICK") + for rec in self: + if rec.operations and rec.operations.picking_type_id.id == 30: + rec.return_type = 'revisi_so' + + if self.operations: + from_return_picking = self.env.context.get('from_return_picking', False) or \ + self.env.context.get('default_line_ids', False) + + if self.line_ids and from_return_picking: + # Hanya update origin, jangan ubah lines + if self.operations.origin: + self.origin = self.operations.origin + _logger.info("📌 Menggunakan product lines dari return wizard, tidak populate ulang.") + + # 🚀 Tapi tetap populate mapping koli jika BU/OUT + if self.operations.picking_type_id.id == 29: + mapping_koli_data = [] + sequence = 10 + tg_product_ids = self.line_ids.mapped('product_id.id') + + for koli_line in self.operations.konfirm_koli_lines: + for move in koli_line.pick_id.move_line_ids_without_package: + if move.product_id.id in tg_product_ids: + mapping_koli_data.append((0, 0, { + 'sequence': sequence, + 'pick_id': koli_line.pick_id.id, + 'product_id': move.product_id.id, + 'qty_done': move.qty_done, + 'qty_return': 0 + })) + sequence += 10 + + self.mapping_koli_ids = mapping_koli_data + _logger.info(f"✅ Created {len(mapping_koli_data)} mapping koli lines (from return wizard)") + return # keluar supaya tidak populate ulang lines + + # Clear existing lines hanya jika tidak dari return picking + self.line_ids = [(5, 0, 0)] + self.mapping_koli_ids = [(5, 0, 0)] # Clear existing mapping koli juga + + # Set origin dari operations + if self.operations.origin: + self.origin = self.operations.origin + + # Auto-populate lines dari move_ids operations + lines_data = [] + sequence = 10 + + # Untuk Odoo 14, gunakan move_ids_without_package atau move_lines + moves_to_check = [] + if hasattr(self.operations, 'move_ids_without_package') and self.operations.move_ids_without_package: + moves_to_check = self.operations.move_ids_without_package + elif hasattr(self.operations, 'move_lines') and self.operations.move_lines: + moves_to_check = self.operations.move_lines + + # Collect product data + product_data = {} + for move in moves_to_check: + if move.product_id and move.product_uom_qty > 0: + product_id = move.product_id.id + if product_id not in product_data: + product_data[product_id] = { + 'product': move.product_id, + 'qty': move.product_uom_qty, + 'uom': move.product_uom.id, + 'name': move.name or move.product_id.display_name + } + + # Buat lines_data + for product_id, data in product_data.items(): + lines_data.append((0, 0, { + 'sequence': sequence, + 'product_id': product_id, + 'product_uom_qty': data['qty'], + 'product_uom': data['uom'], + 'name': data['name'], + })) + sequence += 10 + + if lines_data: + self.line_ids = lines_data + _logger.info(f"✅ Created {len(lines_data)} product lines") + + # Prepare mapping koli jika BU/OUT + mapping_koli_data = [] + sequence = 10 + + if self.operations.picking_type_id.id == 29: + tg_product_ids = [p for p in product_data] + for koli_line in self.operations.konfirm_koli_lines: + for move in koli_line.pick_id.move_line_ids_without_package: + if move.product_id.id in tg_product_ids: + mapping_koli_data.append((0, 0, { + 'sequence': sequence, + 'pick_id': koli_line.pick_id.id, + 'product_id': move.product_id.id, + 'qty_done': move.qty_done + })) + sequence += 10 + + if mapping_koli_data: + self.mapping_koli_ids = mapping_koli_data + _logger.info(f"✅ Created {len(mapping_koli_data)} mapping koli lines") + else: + _logger.info("⚠️ No mapping koli lines created") + else: + _logger.info("⚠️ No product lines created - no valid moves found") + else: + from_return_picking = self.env.context.get('from_return_picking', False) or \ + self.env.context.get('default_line_ids', False) + + if not from_return_picking: + self.line_ids = [(5, 0, 0)] + self.mapping_koli_ids = [(5, 0, 0)] + + self.origin = False + + + def action_populate_lines(self): + """Manual button untuk populate lines - sebagai alternatif""" + self.ensure_one() + if not self.operations: + raise UserError("Pilih BU/OUT atau BU/PICK terlebih dahulu!") + + # Clear existing lines + self.line_ids = [(5, 0, 0)] + + lines_data = [] + sequence = 10 + + # Ambil semua stock moves dari operations + for move in self.operations.move_ids: + if move.product_uom_qty > 0: + lines_data.append((0, 0, { + 'sequence': sequence, + 'product_id': move.product_id.id, + 'product_uom_qty': move.product_uom_qty, + 'product_uom': move.product_uom.id, + 'name': move.name or move.product_id.display_name, + })) + sequence += 10 + + if lines_data: + self.line_ids = lines_data + else: + raise UserError("Tidak ditemukan barang di BU/OUT yang dipilih!") + + @api.constrains('return_type', 'operations') + def _check_required_bu_fields(self): + for record in self: + if record.return_type in ['revisi_so', 'tukar_guling'] and not record.operations: + raise ValidationError("Operations harus diisi") + + @api.constrains('line_ids', 'state') + def _check_product_lines(self): + """Constraint: Product lines harus ada jika state bukan draft""" + for record in self: + if record.state in ('approval_sales', 'approval_logistic', 'approval_finance', + 'done') and not record.line_ids: + raise ValidationError("Product lines harus diisi sebelum submit atau approve!") + + def _validate_product_lines(self): + """Helper method untuk validasi product lines""" + self.ensure_one() + + # Check ada product lines + if not self.line_ids: + raise UserError("Belum ada product lines yang ditambahkan!") + + # Check product sudah diisi + empty_lines = self.line_ids.filtered(lambda line: not line.product_id) + if empty_lines: + raise UserError("Ada product lines yang belum diisi productnya!") + + # Check quantity > 0 + zero_qty_lines = self.line_ids.filtered(lambda line: line.product_uom_qty <= 0) + if zero_qty_lines: + raise UserError("Quantity product tidak boleh kosong atau 0!") + + return True + + def _is_already_returned(self, picking): + return self.env['stock.picking'].search_count([ + ('origin', '=', 'Return of %s' % picking.name), + ('state', '!=', 'cancel') + ]) > 0 + + @api.constrains('return_type', 'operations') + def _check_invoice_on_revisi_so(self): + for record in self: + if record.return_type == 'revisi_so' and record.origin: + invoices = self.env['account.move'].search([ + ('invoice_origin', 'ilike', record.origin), + ('state', 'not in', ['draft', 'cancel']) + ]) + if invoices: + raise ValidationError( + _("Tidak bisa memilih Return Type 'Revisi SO' karena dokumen %s sudah dibuat invoice.") % record.origin + ) + + @api.model + def create(self, vals): + # Generate sequence number + if not vals.get('name') or vals['name'] == 'New': + vals['name'] = self.env['ir.sequence'].next_by_code('tukar.guling') + + # Auto-fill origin from operations + if not vals.get('origin') and vals.get('operations'): + picking = self.env['stock.picking'].browse(vals['operations']) + if picking.origin: + vals['origin'] = picking.origin + if picking.partner_id: + vals['partner_id'] = picking.partner_id.id + + res = super(TukarGuling, self).create(vals) + res.message_post(body=_("CCM Created By %s") % self.env.user.name) + return res + + def copy(self, default=None): + if default is None: + default = {} + + # Generate new sequence untuk duplicate + sequence = self.env['ir.sequence'].search([('code', '=', 'tukar.guling')], limit=1) + if sequence: + default['name'] = sequence.next_by_id() + else: + default['name'] = self.env['ir.sequence'].next_by_code('tukar.guling') or 'copy' + + default.update({ + 'state': 'draft', + 'date': fields.Datetime.now(), + }) + + new_record = super(TukarGuling, self).copy(default) + + # Re-sequence lines + if new_record.line_ids: + for i, line in enumerate(new_record.line_ids): + line.sequence = (i + 1) * 10 + + return new_record + + def write(self, vals): + self.ensure_one() + if self.operations.picking_type_id.id not in [29,30]: + raise UserError("❌ Picking type harus BU/OUT atau BU/PICK") + self._check_invoice_on_revisi_so() + operasi = self.operations.picking_type_id.id + tipe = self.return_type + pp = vals.get('return_type', tipe) + + if not self.operations: + raise UserError("Operations harus diisi!") + + if not self.return_type: + raise UserError("Return Type harus diisi!") + + if operasi == 30 and self.operations.linked_manual_bu_out.state == 'done': + raise UserError("❌ Tidak bisa retur BU/PICK karena BU/OUT sudah done") + if operasi == 30 and pp == 'tukar_guling': + raise UserError("❌ BU/PICK tidak boleh di retur tukar guling") + # else: + # _logger.info("hehhe") + + if 'operations' in vals and not vals.get('origin'): + picking = self.env['stock.picking'].browse(vals['operations']) + if picking.origin: + vals['origin'] = picking.origin + + return super(TukarGuling, self).write(vals) + + def unlink(self): + # if self.state == 'done': + # raise UserError ("Tidak Boleh delete ketika sudahh done") + for record in self: + if record.state == 'done': + raise UserError( + "Tidak bisa hapus pengajuan jika sudah done, set ke draft terlebih dahulu jika ingin menghapus") + ongoing_bu = self.picking_ids.filtered(lambda p: p.state != 'done') + for picking in ongoing_bu: + picking.action_cancel() + return super(TukarGuling, self).unlink() + + def action_view_picking(self): + self.ensure_one() + action = self.env.ref('stock.action_picking_tree_all').read()[0] + pickings = self.picking_ids + if len(pickings) > 1: + action['domain'] = [('id', 'in', pickings.ids)] + elif pickings: + action['views'] = [(self.env.ref('stock.view_picking_form').id, 'form')] + action['res_id'] = pickings.id + return action + + def action_draft(self): + """Reset to draft state""" + for record in self: + if record.state == 'cancel': + record.write({'state': 'draft'}) + else: + raise UserError("Hanya record yang di-cancel yang bisa dikembalikan ke draft") + + def _check_not_allow_tukar_guling_on_bu_pick(self, return_type=None): + operasi = self.operations.picking_type_id.id + tipe = return_type or self.return_type + + if operasi == 30 and self.operations.linked_manual_bu_out.state == 'done': + raise UserError("❌ Tidak bisa retur BU/PICK karena BU/OUT sudah done") + if operasi == 30 and tipe == 'tukar_guling': + raise UserError("❌ BU/PICK tidak boleh di retur tukar guling") + + def action_submit(self): + self.ensure_one() + self._check_not_allow_tukar_guling_on_bu_pick() + + existing_tukar_guling = self.env['tukar.guling'].search([ + ('operations', '=', self.operations.id), + ('id', '!=', self.id), + ('state', '!=', 'cancel'), + ], limit=1) + + if existing_tukar_guling: + raise UserError("BU ini sudah pernah diretur oleh dokumen %s." % existing_tukar_guling.name) + picking = self.operations + if picking.picking_type_id.id == 30 and self.return_type == 'tukar_guling': + raise UserError("❌ BU/PICK tidak boleh di retur tukar guling") + if picking.picking_type_id.id == 29: + if picking.state != 'done': + raise UserError("BU/OUT belum Done!") + elif picking.picking_type_id.id == 30: + linked_bu_out = picking.linked_manual_bu_out + if linked_bu_out and linked_bu_out.state == 'done': + raise UserError("❌ Tidak bisa retur BU/PICK karena BU/OUT suda Done!") + if self._is_already_returned(self.operations): + raise UserError("BU ini sudah pernah diretur oleh dokumen lain.") + + if self.operations.picking_type_id.id == 29: + for line in self.line_ids: + mapping_lines = self.mapping_koli_ids.filtered(lambda x: x.product_id == line.product_id) + total_qty = sum(l.qty_return for l in mapping_lines) + if total_qty != line.product_uom_qty: + raise UserError( + _("Qty di Koli tidak sesuai dengan qty retur untuk produk %s") % line.product_id.display_name) + + self._check_invoice_on_revisi_so() + self._validate_product_lines() + + if self.state != 'draft': + raise UserError("Submit hanya bisa dilakukan dari Draft.") + self.state = 'approval_sales' + + def action_approve(self): + self.ensure_one() + self._validate_product_lines() + self._check_invoice_on_revisi_so() + self._check_not_allow_tukar_guling_on_bu_pick() + + operasi = self.operations.picking_type_id.id + tipe = self.return_type + + if self.operations.picking_type_id.id == 29: + for line in self.line_ids: + mapping_lines = self.mapping_koli_ids.filtered(lambda x: x.product_id == line.product_id) + total_qty = sum(l.qty_return for l in mapping_lines) + if total_qty != line.product_uom_qty: + raise UserError( + _("Qty di Koli tidak sesuai dengan qty retur untuk produk %s") % line.product_id.display_name) + + if operasi == 30 and self.operations.linked_manual_bu_out.state == 'done': + raise UserError("❌ Tidak bisa retur BU/PICK karena BU/OUT sudah done") + if operasi == 30 and tipe == 'tukar_guling': + raise UserError("❌ BU/PICK tidak boleh di retur tukar guling") + # else: + # _logger.info("hehhe") + + if not self.operations: + raise UserError("Operations harus diisi!") + + if not self.return_type: + raise UserError("Return Type harus diisi!") + + now = datetime.now() + + # Cek hak akses berdasarkan state + for rec in self: + if rec.state == 'approval_sales': + if not rec.env.user.has_group('indoteknik_custom.group_role_sales'): + raise UserError("Hanya Sales Manager yang boleh approve tahap ini.") + rec.state = 'approval_finance' + rec.date_sales = now + + elif rec.state == 'approval_finance': + if not rec.env.user.has_group('indoteknik_custom.group_role_fat'): + raise UserError("Hanya Finance Manager yang boleh approve tahap ini.") + rec.state = 'approval_logistic' + rec.date_finance = now + + elif rec.state == 'approval_logistic': + if not rec.env.user.has_group('indoteknik_custom.group_role_logistic'): + raise UserError("Hanya Logistic Manager yang boleh approve tahap ini.") + rec.state = 'done' + rec._create_pickings() + rec.date_logistic = now + + else: + raise UserError("Status ini tidak bisa di-approve.") + + def action_cancel(self): + self.ensure_one() + # picking = self.env['stock.picking'] + + user = self.env.user + if not ( + user.has_group('indoteknik_custom.group_role_sales') or + user.has_group('indoteknik_custom.group_role_fat') or + user.has_group('indoteknik_custom.group_role_logistic') + ): + raise UserWarning('Anda tidak memiliki Permission untuk cancel document') + + bu_done = self.picking_ids.filtered(lambda p: p.state == 'done') + if bu_done: + raise UserError("Dokuemen BU sudah Done, tidak bisa di cancel") + ongoing_bu = self.picking_ids.filtered(lambda p: p.state != 'done') + for picking in ongoing_bu: + picking.action_cancel() + + # if self.state == 'done': + # raise UserError("Tidak bisa cancel jika sudah done") + self.state = 'cancel' + + def _create_pickings(self): + _logger.info("🛠 Starting _create_pickings()") + for record in self: + if not record.operations: + raise UserError("BU/OUT dari field operations tidak ditemukan.") + + bu_out = record.operations + mapping_koli = record.mapping_koli_ids + + # Constants + PARTNER_LOCATION_ID = 5 + BU_OUTPUT_LOCATION_ID = 60 + BU_STOCK_LOCATION_ID = 57 + + # Picking Types + srt_type = self.env['stock.picking.type'].browse(73) + ort_type = self.env['stock.picking.type'].browse(74) + bu_pick_type = self.env['stock.picking.type'].browse(30) + bu_out_type = self.env['stock.picking.type'].browse(29) + + created_returns = [] + + ### ======== SRT dari BU/OUT ========= + srt_return_lines = [] + for prod in mapping_koli.mapped('product_id'): + qty_total = sum(mk.qty_return for mk in mapping_koli.filtered(lambda m: m.product_id == prod)) + move = bu_out.move_lines.filtered(lambda m: m.product_id == prod) + if not move: + raise UserError(f"Move BU/OUT tidak ditemukan untuk produk {prod.display_name}") + srt_return_lines.append((0, 0, { + 'product_id': prod.id, + 'quantity': qty_total, + 'move_id': move.id, + })) + _logger.info(f"📟 SRT line: {prod.display_name} | qty={qty_total}") + + srt_picking = None + if srt_return_lines: + srt_wizard = self.env['stock.return.picking'].with_context({ + 'active_id': bu_out.id, + 'default_location_id': PARTNER_LOCATION_ID, + 'default_location_dest_id': BU_OUTPUT_LOCATION_ID, + 'from_ui': False, + }).create({ + 'picking_id': bu_out.id, + 'location_id': PARTNER_LOCATION_ID, + 'original_location_id': BU_OUTPUT_LOCATION_ID, + 'product_return_moves': srt_return_lines + }) + srt_vals = srt_wizard.create_returns() + srt_picking = self.env['stock.picking'].browse(srt_vals['res_id']) + srt_picking.write({ + 'location_id': PARTNER_LOCATION_ID, + 'location_dest_id': BU_OUTPUT_LOCATION_ID, + 'group_id': bu_out.group_id.id, + 'tukar_guling_id': record.id, + 'sale_order': record.origin + }) + created_returns.append(srt_picking) + _logger.info(f"✅ SRT created: {srt_picking.name}") + record.message_post( + body=f"📦 <b>{srt_picking.name}</b> created by <b>{self.env.user.name}</b> (state: <b>{srt_picking.state}</b>)") + + ### ======== ORT dari BU/PICK ========= + ort_pickings = [] + is_retur_from_bu_pick = record.operations.picking_type_id.id == 30 + picks_to_return = [record.operations] if is_retur_from_bu_pick else mapping_koli.mapped('pick_id') or line.product_uom_qty + + for pick in picks_to_return: + ort_return_lines = [] + if is_retur_from_bu_pick: + # Ambil dari tukar.guling.line + for line in record.line_ids: + move = pick.move_lines.filtered(lambda m: m.product_id == line.product_id) + if not move: + raise UserError( + f"Move tidak ditemukan di BU/PICK {pick.name} untuk {line.product_id.display_name}") + ort_return_lines.append((0, 0, { + 'product_id': line.product_id.id, + 'quantity': line.product_uom_qty, + 'move_id': move.id, + })) + _logger.info(f"📟 ORT (BU/PICK langsung) | {pick.name} | {line.product_id.display_name} | qty={line.product_uom_qty}") + else: + # Ambil dari mapping koli + for mk in mapping_koli.filtered(lambda m: m.pick_id == pick): + move = pick.move_lines.filtered(lambda m: m.product_id == mk.product_id) + if not move: + raise UserError( + f"Move tidak ditemukan di BU/PICK {pick.name} untuk {mk.product_id.display_name}") + ort_return_lines.append((0, 0, { + 'product_id': mk.product_id.id, + 'quantity': mk.qty_return, + 'move_id': move.id, + })) + _logger.info(f"📟 ORT (mapping koli) | {pick.name} | {mk.product_id.display_name} | qty={mk.qty_return}") + + if ort_return_lines: + ort_wizard = self.env['stock.return.picking'].with_context({ + 'active_id': pick.id, + 'default_location_id': BU_OUTPUT_LOCATION_ID, + 'default_location_dest_id': BU_STOCK_LOCATION_ID, + 'from_ui': False, + }).create({ + 'picking_id': pick.id, + 'location_id': BU_OUTPUT_LOCATION_ID, + 'original_location_id': BU_STOCK_LOCATION_ID, + 'product_return_moves': ort_return_lines + }) + ort_vals = ort_wizard.create_returns() + ort_picking = self.env['stock.picking'].browse(ort_vals['res_id']) + ort_picking.write({ + 'location_id': BU_OUTPUT_LOCATION_ID, + 'location_dest_id': BU_STOCK_LOCATION_ID, + 'group_id': bu_out.group_id.id, + 'tukar_guling_id': record.id, + 'sale_order': record.origin + }) + created_returns.append(ort_picking) + ort_pickings.append(ort_picking) + _logger.info(f"✅ ORT created: {ort_picking.name}") + record.message_post( + body=f"📦 <b>{ort_picking.name}</b> created by <b>{self.env.user.name}</b> (state: <b>{ort_picking.state}</b>)") + + ### ======== Tukar Guling: BU/OUT dan BU/PICK baru ======== + if record.return_type == 'tukar_guling': + + # BU/PICK Baru dari ORT + for ort_p in ort_pickings: + return_lines = [] + for move in ort_p.move_lines: + if move.product_uom_qty > 0: + return_lines.append((0, 0, { + 'product_id': move.product_id.id, + 'quantity': move.product_uom_qty, + 'move_id': move.id + })) + _logger.info( + f"🔁 BU/PICK baru dari ORT {ort_p.name} | {move.product_id.display_name} | qty={move.product_uom_qty}") + + if not return_lines: + _logger.warning(f"❌ Tidak ada qty > 0 di ORT {ort_p.name}, dilewati.") + continue + + bu_pick_wizard = self.env['stock.return.picking'].with_context({ + 'active_id': ort_p.id, + 'default_location_id': BU_STOCK_LOCATION_ID, + 'default_location_dest_id': BU_OUTPUT_LOCATION_ID, + 'from_ui': False, + }).create({ + 'picking_id': ort_p.id, + 'location_id': BU_STOCK_LOCATION_ID, + 'original_location_id': BU_OUTPUT_LOCATION_ID, + 'product_return_moves': return_lines + }) + bu_pick_vals = bu_pick_wizard.create_returns() + new_pick = self.env['stock.picking'].browse(bu_pick_vals['res_id']) + new_pick.write({ + 'location_id': BU_STOCK_LOCATION_ID, + 'location_dest_id': BU_OUTPUT_LOCATION_ID, + 'group_id': bu_out.group_id.id, + 'tukar_guling_id': record.id, + 'sale_order': record.origin + }) + new_pick.action_assign() # Penting agar bisa trigger check koli + new_pick.action_confirm() + created_returns.append(new_pick) + _logger.info(f"✅ BU/PICK Baru dari ORT created: {new_pick.name}") + record.message_post( + body=f"📦 <b>{new_pick.name}</b> created by <b>{self.env.user.name}</b> (state: <b>{new_pick.state}</b>)") + + # BU/OUT Baru dari SRT + if srt_picking: + return_lines = [] + for move in srt_picking.move_lines: + if move.product_uom_qty > 0: + return_lines.append((0, 0, { + 'product_id': move.product_id.id, + 'quantity': move.product_uom_qty, + 'move_id': move.id, + })) + _logger.info( + f"🔁 BU/OUT baru dari SRT | {move.product_id.display_name} | qty={move.product_uom_qty}") + + bu_out_wizard = self.env['stock.return.picking'].with_context({ + 'active_id': srt_picking.id, + 'default_location_id': BU_OUTPUT_LOCATION_ID, + 'default_location_dest_id': PARTNER_LOCATION_ID, + 'from_ui': False, + }).create({ + 'picking_id': srt_picking.id, + 'location_id': BU_OUTPUT_LOCATION_ID, + 'original_location_id': PARTNER_LOCATION_ID, + 'product_return_moves': return_lines + }) + bu_out_vals = bu_out_wizard.create_returns() + new_out = self.env['stock.picking'].browse(bu_out_vals['res_id']) + new_out.write({ + 'location_id': BU_OUTPUT_LOCATION_ID, + 'location_dest_id': PARTNER_LOCATION_ID, + 'group_id': bu_out.group_id.id, + 'tukar_guling_id': record.id, + 'sale_order': record.origin + }) + created_returns.append(new_out) + _logger.info(f"✅ BU/OUT Baru dari SRT created: {new_out.name}") + record.message_post( + body=f"📦 <b>{new_out.name}</b> created by <b>{self.env.user.name}</b> (state: <b>{new_out.state}</b>)") + + if not created_returns: + raise UserError("Tidak ada dokumen retur berhasil dibuat.") + + _logger.info("✅ Finished _create_pickings(). Created %s returns: %s", + len(created_returns), + ", ".join([p.name for p in created_returns])) + + +class TukarGulingLine(models.Model): + _name = 'tukar.guling.line' + _description = 'Tukar Guling Line' + _order = 'sequence, id' + + sequence = fields.Integer('Sequence', default=10, copy=False) + tukar_guling_id = fields.Many2one('tukar.guling', string='Tukar Guling', required=True, ondelete='cascade') + product_id = fields.Many2one('product.product', string='Product', required=True) + product_uom_qty = fields.Float('Quantity', digits='Product Unit of Measure', required=True, default=1.0) + product_uom = fields.Many2one('uom.uom', string='Unit of Measure') + name = fields.Text('Description') + + @api.constrains('product_uom_qty') + def _check_qty_change_allowed(self): + for rec in self: + if rec.tukar_guling_id and rec.tukar_guling_id.state not in ['draft', 'cancel']: + raise ValidationError("Tidak bisa mengubah Quantity karena status dokumen bukan Draft atau Cancel.") + + def unlink(self): + for rec in self: + if rec.tukar_guling_id and rec.tukar_guling_id.state not in ['draft', 'cancel']: + raise UserError("Tidak bisa menghapus data karena status dokumen bukan Draft atau Cancel.") + return super(TukarGulingLine, self).unlink() + + @api.model_create_multi + def create(self, vals_list): + """Override create to auto-assign sequence""" + for vals in vals_list: + if 'sequence' not in vals or vals.get('sequence', 0) <= 0: + # Get max sequence untuk tukar_guling yang sama + tukar_guling_id = vals.get('tukar_guling_id') + if tukar_guling_id: + max_seq = self.search([ + ('tukar_guling_id', '=', tukar_guling_id) + ], order='sequence desc', limit=1) + vals['sequence'] = (max_seq.sequence or 0) + 10 + else: + vals['sequence'] = 10 + return super(TukarGulingLine, self).create(vals_list) + + @api.onchange('product_id') + def _onchange_product_id(self): + if self.product_id: + self.name = self.product_id.display_name + self.product_uom = self.product_id.uom_id + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + tukar_guling_id = fields.Many2one('tukar.guling', string='Tukar Guling Ref') + + def button_validate(self): + res = super(StockPicking, self).button_validate() + + for picking in self: + if picking.tukar_guling_id: + message = _( + "📦 <b>%s</b> Validated by <b>%s</b> Status Changed <b>%s</b> at <b>%s</b>." + ) % ( + picking.name, + # picking.picking_type_id.name, + picking.env.user.name, + picking.state, + fields.Datetime.now().strftime("%d/%m/%Y %H:%M") + ) + picking.tukar_guling_id.message_post(body=message) + + return res + + + +class TukarGulingMappingKoli(models.Model): + _name = 'tukar.guling.mapping.koli' + _description = 'Mapping Koli di Tukar Guling' + + tukar_guling_id = fields.Many2one('tukar.guling', string='Tukar Guling') + pick_id = fields.Many2one('stock.picking', string='BU PICK') + product_id = fields.Many2one('product.product', string='Product') + qty_done = fields.Float(string='Qty Done BU PICK') + qty_return = fields.Float(string='Qty diretur') + sequence = fields.Integer(string='Sequence', default=10) + @api.constrains('qty_return') + def _check_qty_return_editable(self): + for rec in self: + if rec.tukar_guling_id and rec.tukar_guling_id.state not in ['draft', 'cancel']: + raise ValidationError("Tidak Bisa ubah qty retur jika status sudah approval atau done.") + + def unlink(self): + for rec in self: + if rec.tukar_guling_id and rec.tukar_guling_id.state not in ['draft', 'cancel']: + raise UserError("Tidak bisa menghapus Mapping Koli karena status Tukar Guling bukan Draft atau Cancel.") + return super(TukarGulingMappingKoli, self).unlink()
\ No newline at end of file diff --git a/indoteknik_custom/models/tukar_guling_po.py b/indoteknik_custom/models/tukar_guling_po.py new file mode 100644 index 00000000..7c9680f8 --- /dev/null +++ b/indoteknik_custom/models/tukar_guling_po.py @@ -0,0 +1,662 @@ +from email.policy import default + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError, ValidationError +import logging +from datetime import datetime + +_logger = logging.getLogger(__name__) + + +class TukarGulingPO(models.Model): + _name = 'tukar.guling.po' + _description = 'Tukar Guling PO' + _inherit = ['mail.thread', 'mail.activity.mixin'] + + vendor_id = fields.Many2one('res.partner', string='Vendor Name', readonly=True) + origin = fields.Char(string='Origin PO') + is_po = fields.Boolean('Is PO', default=True) + is_so = fields.Boolean('Is SO', default=False) + name = fields.Char(string='Name', required=True) + po_picking_ids = fields.One2many( + 'stock.picking', + 'tukar_guling_po_id', + string='Picking Reference', + ) + name = fields.Char('Number', required=True, copy=False, readonly=True, default='New') + date = fields.Datetime('Date', default=fields.Datetime.now, required=True) + date_purchase = fields.Datetime('Date Approve Purchase', readonly=True) + date_finance = fields.Datetime('Date Approve Finance', readonly=True) + date_logistic = fields.Datetime('Date Approve Logistic', readonly=True) + operations = fields.Many2one( + 'stock.picking', + string='Operations', + domain=[ + ('picking_type_id.id', 'in', [75, 28]), + ('state', '=', 'done') + ], help='Nomor BU INPUT atau BU PUT', tracking=3 + ) + ba_num = fields.Char('Nomor BA', tracking=3) + return_type = fields.Selection([ + ('revisi_po', 'Revisi PO'), + ('tukar_guling', 'Tukar Guling'), + ], string='Return Type', required=True, tracking=3) + notes = fields.Text('Notes', tracking=3) + tukar_guling_po_id = fields.Many2one('tukar.guling.po', string='Tukar Guling PO', ondelete='cascade') + line_ids = fields.One2many('tukar.guling.line.po', 'tukar_guling_po_id', string='Product Lines', tracking=3) + state = fields.Selection([ + ('draft', 'Draft'), + ('approval_purchase', 'Approval Purchasing'), + ('approval_finance', 'Approval Finance'), + ('approval_logistic', 'Approval Logistic'), + ('done', 'Done'), + ('cancel', 'Cancel'), + ], string='Status', default='draft', tracking=3) + + @api.model + def create(self, vals): + # Generate sequence number + # ven_name = self.origin.search([('name', 'ilike', vals['origin'])]) + if not vals.get('name') or vals['name'] == 'New': + vals['name'] = self.env['ir.sequence'].next_by_code('tukar.guling.po') + + # Auto-fill origin from operations + if not vals.get('origin') and vals.get('operations'): + picking = self.env['stock.picking'].browse(vals['operations']) + if picking.origin: + vals['origin'] = picking.origin + if picking.group_id.id: + vals['vendor_id'] = picking.group_id.partner_id.id + + res = super(TukarGulingPO, self).create(vals) + res.message_post(body=_("VCM Created By %s") % self.env.user.name) + + return res + + @api.constrains('return_type', 'operations') + def _check_bill_on_revisi_po(self): + for record in self: + if record.return_type == 'revisi_po' and record.origin: + bills = self.env['account.move'].search([ + ('invoice_origin', 'ilike', record.origin), + ('move_type', '=', 'in_invoice'), # hanya vendor bill + ('state', 'not in', ['draft', 'cancel']) + ]) + if bills: + raise ValidationError( + _("Tidak bisa memilih Return Type 'Revisi PO' karena PO %s sudah dibuat vendor bill.") % record.origin + ) + + @api.onchange('operations') + def _onchange_operations(self): + """Auto-populate lines ketika operations dipilih""" + if self.operations.picking_type_id.id not in [75, 28]: + raise UserError("❌ Picking type harus BU/INPUT atau BU/PUT") + + if self.operations: + from_return_picking = self.env.context.get('from_return_picking', False) or \ + self.env.context.get('default_line_ids', False) + + if self.line_ids and from_return_picking: + # Hanya update origin, jangan ubah lines + if self.operations.origin: + self.origin = self.operations.origin + return + + if from_return_picking: + # Gunakan qty dari context (stock return wizard) + default_lines = self.env.context.get('default_line_ids', []) + parsed_lines = [] + sequence = 10 + for line_data in default_lines: + if isinstance(line_data, (list, tuple)) and len(line_data) == 3: + vals = line_data[2] + parsed_lines.append((0, 0, { + 'sequence': sequence, + 'product_id': vals.get('product_id'), + 'product_uom_qty': vals.get('quantity'), + 'product_uom': self.env['product.product'].browse(vals.get('product_id')).uom_id.id, + 'name': self.env['product.product'].browse(vals.get('product_id')).display_name, + })) + sequence += 10 + + self.line_ids = parsed_lines + return + else: + self.line_ids = [(5, 0, 0)] + + # Set origin dari operations + if self.operations.origin: + self.origin = self.operations.origin + + # Auto-populate lines dari move_ids operations + lines_data = [] + sequence = 10 + + # Untuk Odoo 14, gunakan move_ids_without_package atau move_lines + moves_to_check = [] + + # 1. move_ids_without_package (standard di Odoo 14) + if hasattr(self.operations, 'move_ids_without_package') and self.operations.move_ids_without_package: + moves_to_check = self.operations.move_ids_without_package + # 2. move_lines (backup untuk versi lama) + elif hasattr(self.operations, 'move_lines') and self.operations.move_lines: + moves_to_check = self.operations.move_lines + + for move in moves_to_check: + _logger.info( + f"Move: {move.name}, Product: {move.product_id.name if move.product_id else 'No Product'}, Qty: {move.product_uom_qty}, State: {move.state}") + + # Ambil semua move yang ada quantity + if move.product_id and move.product_uom_qty > 0: + lines_data.append((0, 0, { + 'sequence': sequence, + 'product_id': move.product_id.id, + 'product_uom_qty': move.product_uom_qty, + 'product_uom': move.product_uom.id, + 'name': move.name or move.product_id.display_name, + })) + sequence += 10 + + if lines_data: + self.line_ids = lines_data + _logger.info(f"Created {len(lines_data)} lines") + else: + _logger.info("No lines created - no valid moves found") + else: + # Clear lines jika operations dikosongkan, kecuali dari return picking + from_return_picking = self.env.context.get('from_return_picking', False) or \ + self.env.context.get('default_line_ids', False) + + if not from_return_picking: + self.line_ids = [(5, 0, 0)] + + self.origin = False + + def _check_not_allow_tukar_guling_on_bu_input(self, return_type=None): + operasi = self.operations.picking_type_id.id + tipe = return_type or self.return_type + + if operasi == 28 and self.operations.linked_manual_bu_out.state == 'done': + raise UserError("❌ Tidak bisa retur BU/INPUT karena BU/PUT sudah done") + if operasi == 28 and tipe == 'tukar_guling': + raise UserError("❌ BU/INPUT tidak boleh di retur tukar guling") + + def action_populate_lines(self): + """Manual button untuk populate lines - sebagai alternatif""" + self.ensure_one() + if not self.operations: + raise UserError("Pilih BU/OUT atau BU/PICK terlebih dahulu!") + + # Clear existing lines + self.line_ids = [(5, 0, 0)] + + lines_data = [] + sequence = 10 + + # Ambil semua stock moves dari operations + for move in self.operations.move_ids: + if move.product_uom_qty > 0: + lines_data.append((0, 0, { + 'sequence': sequence, + 'product_id': move.product_id.id, + 'product_uom_qty': move.product_uom_qty, + 'product_uom': move.product_uom.id, + 'name': move.name or move.product_id.display_name, + })) + sequence += 10 + + if lines_data: + self.line_ids = lines_data + else: + raise UserError("Tidak ditemukan barang di BU/OUT yang dipilih!") + + @api.constrains('return_type', 'operations') + def _check_required_bu_fields(self): + for record in self: + if record.return_type in ['revisi_po', 'tukar_guling'] and not record.operations: + raise ValidationError("Operations harus diisi") + + @api.constrains('line_ids', 'state') + def _check_product_lines(self): + """Constraint: Product lines harus ada jika state bukan draft""" + for record in self: + if record.state in ('approval_purchase', 'approval_finance', 'approval_logistic', + 'done') and not record.line_ids: + raise ValidationError("Product lines harus diisi sebelum submit atau approve!") + + def _validate_product_lines(self): + """Helper method untuk validasi product lines""" + self.ensure_one() + + # Check ada product lines + if not self.line_ids: + raise UserError("Belum ada product lines yang ditambahkan!") + + # Check product sudah diisi + empty_lines = self.line_ids.filtered(lambda line: not line.product_id) + if empty_lines: + raise UserError("Ada product lines yang belum diisi productnya!") + + # Check quantity > 0 + zero_qty_lines = self.line_ids.filtered(lambda line: line.product_uom_qty <= 0) + if zero_qty_lines: + raise UserError("Quantity product tidak boleh kosong atau 0!") + + return True + + def _is_already_returned(self, picking): + return self.env['stock.picking'].search_count([ + ('origin', '=', 'Return of %s' % picking.name), + # ('returned_from_id', '=', picking.id), + ('state', 'not in', ['cancel', 'draft']), + ]) > 0 + + def copy(self, default=None): + if default is None: + default = {} + + # Generate new sequence untuk duplicate + sequence = self.env['ir.sequence'].search([('code', '=', 'tukar.guling.po')], limit=1) + if sequence: + default['name'] = sequence.next_by_id() + else: + default['name'] = self.env['ir.sequence'].next_by_code('tukar.guling.po') or 'copy' + + default.update({ + 'state': 'draft', + 'date': fields.Datetime.now(), + }) + + new_record = super(TukarGulingPO, self).copy(default) + + # Re-sequence lines + if new_record.line_ids: + for i, line in enumerate(new_record.line_ids): + line.sequence = (i + 1) * 10 + + return new_record + + def write(self, vals): + if self.operations.picking_type_id.id not in [75, 28]: + raise UserError("❌ Tidak bisa retur bukan BU/INPUT atau BU/PUT!") + self._check_bill_on_revisi_po() + tipe = vals.get('return_type', self.return_type) + + if self.operations and self.operations.picking_type_id.id == 28 and tipe == 'tukar_guling': + group = self.operations.group_id + if group: + # Cari BU/PUT dalam group yang sama + bu_put = self.env['stock.picking'].search([ + ('group_id', '=', group.id), + ('picking_type_id.id', '=', 75), # 75 = ID BU/PUT + ('state', '=', 'done') + ], limit=1) + + if bu_put: + raise UserError("❌ Tidak bisa retur BU/INPUT karena BU/PUT sudah Done!") + + if self.operations.picking_type_id.id == 28 and tipe == 'tukar_guling': + raise UserError("❌ BU/INPUT tidak boleh di retur tukar guling") + + # if self.operations.picking_type_id.id != 28: + # if self._is_already_returned(self.operations): + # raise UserError("BU ini sudah pernah diretur oleh dokumen lain.") + if 'operations' in vals and not vals.get('origin'): + picking = self.env['stock.picking'].browse(vals['operations']) + if picking.origin: + vals['origin'] = picking.origin + + return super(TukarGulingPO, self).write(vals) + + def unlink(self): + for record in self: + if record.state == 'done': + raise UserError("Tidak bisa hapus pengajuan jika sudah done, set ke draft terlebih dahulu") + ongoing_bu = self.po_picking_ids.filtered(lambda p: p.state != 'done') + for picking in ongoing_bu: + picking.action_cancel() + return super(TukarGulingPO, self).unlink() + + def action_view_picking(self): + self.ensure_one() + action = self.env.ref('stock.action_picking_tree_all').read()[0] + pickings = self.po_picking_ids + if len(pickings) > 1: + action['domain'] = [('id', 'in', pickings.ids)] + elif pickings: + action['views'] = [(self.env.ref('stock.view_picking_form').id, 'form')] + action['res_id'] = pickings.id + return action + + def action_draft(self): + """Reset to draft state""" + for record in self: + if record.state == 'cancel': + record.write({'state': 'draft'}) + else: + raise UserError("Hanya record yang di-cancel yang bisa dikembalikan ke draft") + + def action_submit(self): + self.ensure_one() + self._check_bill_on_revisi_po() + self._validate_product_lines() + self._check_not_allow_tukar_guling_on_bu_input() + + if self.operations.picking_type_id.id == 28: + group = self.operations.group_id + if group: + # Cari BU/PUT dalam group yang sama + bu_put = self.env['stock.picking'].search([ + ('group_id', '=', group.id), + ('picking_type_id.id', '=', 75), + ('state', '=', 'done') + ], limit=1) + + if bu_put: + raise UserError("❌ Tidak bisa retur BU/INPUT karena BU/PUT sudah Done!") + + picking = self.operations + pick_id = self.operations.picking_type_id.id + if pick_id == 75: + if picking.state != 'done': + raise UserError("BU/PUT belum Done!") + + if pick_id not in [75, 28]: + raise UserError("❌ Tidak bisa retur bukan BU/INPUT atau BU/PUT!") + + if self._is_already_returned(self.operations): + raise UserError("BU ini sudah pernah diretur oleh dokumen lain.") + + if self.state != 'draft': + raise UserError("Submit hanya bisa dilakukan dari Draft.") + self.state = 'approval_purchase' + + def action_approve(self): + self.ensure_one() + self._validate_product_lines() + self._check_bill_on_revisi_po() + self._check_not_allow_tukar_guling_on_bu_input() + + if not self.operations: + raise UserError("Operations harus diisi!") + + if not self.return_type: + raise UserError("Return Type harus diisi!") + + now = datetime.now() + + # Cek hak akses berdasarkan state + for rec in self: + if rec.state == 'approval_purchase': + if not rec.env.user.has_group('indoteknik_custom.group_role_sales'): + raise UserError("Hanya Sales Manager yang boleh approve tahap ini.") + rec.state = 'approval_finance' + rec.date_purchase = now + + elif rec.state == 'approval_finance': + if not rec.env.user.has_group('indoteknik_custom.group_role_fat'): + raise UserError("Hanya Finance Manager yang boleh approve tahap ini.") + rec.state = 'approval_logistic' + rec.date_finance = now + + elif rec.state == 'approval_logistic': + if not rec.env.user.has_group('indoteknik_custom.group_role_logistic'): + raise UserError("Hanya Logistic Manager yang boleh approve tahap ini.") + rec.state = 'done' + rec._create_pickings() + rec.date_logistic = now + else: + raise UserError("Status ini tidak bisa di-approve.") + + def action_cancel(self): + self.ensure_one() + # if self.state == 'done': + # raise UserError("Tidak bisa cancel jika sudah done") + + user = self.env.user + if not ( + user.has_group('indoteknik_custom.group_role_sales') or + user.has_group('indoteknik_custom.group_role_fat') or + user.has_group('indoteknik_custom.group_role_logistic') + ): + raise UserWarning('Anda tidak memiliki Permission untuk cancel document') + + + bu_done = self.po_picking_ids.filtered(lambda p: p.state == 'done') + if bu_done: + raise UserError("Dokuemn BU sudah Done, tidak bisa di cancel") + ongoing_bu = self.po_picking_ids.filtered(lambda p: p.state != 'done') + for picking in ongoing_bu: + picking.action_cancel() + self.state = 'cancel' + + def _create_pickings(self): + for record in self: + if not record.operations: + raise UserError("BU Operations belum dipilih.") + + created_returns = self.env['stock.picking'] + + group = record.operations.group_id + bu_inputs = bu_puts = self.env['stock.picking'] + + # Buat qty map awal dari line_ids + bu_input_qty_map = { + line.product_id.id: line.product_uom_qty + for line in record.line_ids + if line.product_id and line.product_uom_qty > 0 + } + bu_put_qty_map = bu_input_qty_map.copy() + + if group: + po_pickings = self.env['stock.picking'].search([ + ('group_id', '=', group.id), + ('state', '=', 'done') + ]) + bu_inputs = po_pickings.filtered(lambda p: p.picking_type_id.id == 28) + bu_puts = po_pickings.filtered(lambda p: p.picking_type_id.id == 75) + else: + raise UserError("Group ID tidak ditemukan pada BU Operations.") + + def _create_return_from_picking(picking, qty_map): + if not picking: + return self.env['stock.picking'] + + grup = record.operations.group_id + + # Tentukan lokasi + PARTNER_LOCATION_ID = 4 + BU_INPUT_LOCATION_ID = 58 + BU_STOCK_LOCATION_ID = 57 + + picking_type = picking.picking_type_id.id + if picking_type == 28: + default_location_id = BU_INPUT_LOCATION_ID + default_location_dest_id = PARTNER_LOCATION_ID + elif picking_type == 75: + default_location_id = BU_STOCK_LOCATION_ID + default_location_dest_id = BU_INPUT_LOCATION_ID + elif picking_type == 77: + default_location_id = BU_INPUT_LOCATION_ID + default_location_dest_id = BU_STOCK_LOCATION_ID + elif picking_type == 76: + default_location_id = PARTNER_LOCATION_ID + default_location_dest_id = BU_INPUT_LOCATION_ID + else: + return self.env['stock.picking'] + + return_context = dict(self.env.context) + return_context.update({ + 'active_id': picking.id, + 'default_location_id': default_location_id, + 'default_location_dest_id': default_location_dest_id, + 'from_ui': False, + }) + + return_wizard = self.env['stock.return.picking'].with_context(return_context).create({ + 'picking_id': picking.id, + 'location_id': default_location_dest_id, + 'original_location_id': default_location_id + }) + + return_lines = [] + moves = getattr(picking, 'move_ids_without_package', False) or picking.move_lines + + for move in moves: + product = move.product_id + if not product: + continue + + pid = product.id + available_qty = qty_map.get(pid, 0.0) + move_qty = move.product_uom_qty + allocate_qty = min(available_qty, move_qty) + + if allocate_qty <= 0: + continue + + return_lines.append((0, 0, { + 'product_id': pid, + 'quantity': allocate_qty, + 'move_id': move.id, + })) + qty_map[pid] -= allocate_qty + + _logger.info(f"📦 Alokasi {allocate_qty} untuk {product.display_name} | Sisa: {qty_map[pid]}") + + if not return_lines: + # Tukar Guling lanjut dari PRT/VRT + if picking.picking_type_id.id in [76, 77]: + for move in moves: + if move.product_uom_qty > 0: + return_lines.append((0, 0, { + 'product_id': move.product_id.id, + 'quantity': move.product_uom_qty, + 'move_id': move.id, + })) + _logger.info( + f"🔁 TG lanjutan: Alokasi {move.product_uom_qty} untuk {move.product_id.display_name}") + else: + _logger.warning( + f"⏭️ Skipped return picking {picking.name}, tidak ada qty yang bisa dialokasikan.") + return self.env['stock.picking'] + + return_wizard.product_return_moves = return_lines + return_vals = return_wizard.create_returns() + return_picking = self.env['stock.picking'].browse(return_vals.get('res_id')) + + return_picking.write({ + 'location_id': default_location_id, + 'location_dest_id': default_location_dest_id, + 'group_id': grup.id, + 'tukar_guling_po_id': record.id, + }) + record.message_post( + body=f"📦 <b>{return_picking.name}</b> " + f"<b>{return_picking.picking_type_id.display_name}</b> " + f"Created by <b>{self.env.user.name}</b> " + f"status <b>{return_picking.state}</b> " + f"at <b>{fields.Datetime.now().strftime('%d/%m/%Y %H:%M')}</b>", + message_type="comment", + subtype_id=self.env.ref("mail.mt_note").id, + ) + + return return_picking + + # ============================ + # Eksekusi utama return logic + # ============================ + + if record.operations.picking_type_id.id == 28: + # Dari BU INPUT langsung buat PRT + prt = _create_return_from_picking(record.operations, bu_input_qty_map) + if prt: + created_returns |= prt + else: + # ✅ Pairing BU PUT ↔ BU INPUT + # Temukan index dari BU PUT yang dipilih user + try: + bu_put_index = sorted(bu_puts, key=lambda p: p.name).index(record.operations) + except ValueError: + raise UserError("Dokumen BU PUT yang dipilih tidak ditemukan dalam daftar BU PUT.") + + # Ambil pasangannya di BU INPUT (asumsi urutan sejajar) + sorted_bu_puts = sorted(bu_puts, key=lambda p: p.name) + sorted_bu_inputs = sorted(bu_inputs, key=lambda p: p.name) + + if bu_put_index >= len(sorted_bu_inputs): + raise UserError("Tidak ditemukan pasangan BU INPUT untuk BU PUT yang dipilih.") + + paired = [(sorted_bu_puts[bu_put_index], sorted_bu_inputs[bu_put_index])] + + for bu_put, bu_input in paired: + vrt = _create_return_from_picking(bu_put, bu_put_qty_map) + if vrt: + created_returns |= vrt + + prt = _create_return_from_picking(bu_input, bu_input_qty_map) + if prt: + created_returns |= prt + + # 🌀 Tukar Guling: buat dokumen baru dari PRT & VRT + if record.return_type == 'tukar_guling': + for prt in created_returns.filtered(lambda p: p.picking_type_id.id == 76): + bu_input = _create_return_from_picking(prt, bu_input_qty_map) + if bu_input: + created_returns |= bu_input + + for vrt in created_returns.filtered(lambda p: p.picking_type_id.id == 77): + bu_put = _create_return_from_picking(vrt, bu_put_qty_map) + if bu_put: + created_returns |= bu_put + + if not created_returns: + raise UserError("Tidak ada dokumen retur yang berhasil dibuat.") + + +class TukarGulingLinePO(models.Model): + _name = 'tukar.guling.line.po' + _description = 'Tukar Guling PO Line' + + sequence = fields.Integer('Sequence', default=10, copy=False) + product_id = fields.Many2one('product.product', string='Product', required=True) + tukar_guling_po_id = fields.Many2one('tukar.guling.po', string='Tukar Guling PO', ondelete='cascade') + product_uom_qty = fields.Float('Quantity', digits='Product Unit of Measure', required=True, default=1.0) + product_uom = fields.Many2one('uom.uom', string='Unit of Measure') + name = fields.Text('Description') + + @api.constrains('product_uom_qty') + def _check_qty_change_allowed(self): + for rec in self: + if rec.tukar_guling_id and rec.tukar_guling_po_id.state not in ['draft', 'cancel']: + raise ValidationError("Tidak bisa mengubah Quantity karena status dokumen bukan Draft atau Cancel.") + + def unlink(self): + for rec in self: + if rec.tukar_guling_po_id and rec.tukar_guling_po_id.state not in ['draft', 'cancel']: + raise UserError("Tidak bisa menghapus data karena status dokumen bukan Draft atau Cancel.") + return super(TukarGulingLinePO, self).unlink() + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + tukar_guling_po_id = fields.Many2one('tukar.guling.po', string='Tukar Guling PO Ref') + + + def button_validate(self): + res = super(StockPicking, self).button_validate() + for picking in self: + if picking.tukar_guling_po_id: + message = _( + "📦 <b>%s</b> Validated by <b>%s</b> Status Changed <b>%s</b> at <b>%s</b>." + ) % ( + picking.name, + # picking.picking_type_id.name, + picking.env.user.name, + picking.state, + fields.Datetime.now().strftime("%d/%m/%Y %H:%M") + ) + picking.tukar_guling_po_id.message_post(body=message) + + return res
\ No newline at end of file diff --git a/indoteknik_custom/models/uangmuka_pembelian.py b/indoteknik_custom/models/uangmuka_pembelian.py index ba41f814..13d51dcf 100644 --- a/indoteknik_custom/models/uangmuka_pembelian.py +++ b/indoteknik_custom/models/uangmuka_pembelian.py @@ -57,6 +57,8 @@ class UangmukaPembelian(models.TransientModel): account_move = request.env['account.move'].create([param_header]) _logger.info('Success Create Uang Muka Pembelian %s' % account_move.name) + account_move.purchase_order_id = order.id # isi field purchase_order_id + if order.partner_id.parent_id: partner_id = order.partner_id.parent_id.id else: diff --git a/indoteknik_custom/models/user_pengajuan_tempo_request.py b/indoteknik_custom/models/user_pengajuan_tempo_request.py index 87227764..600381c0 100644 --- a/indoteknik_custom/models/user_pengajuan_tempo_request.py +++ b/indoteknik_custom/models/user_pengajuan_tempo_request.py @@ -371,7 +371,7 @@ class UserPengajuanTempoRequest(models.Model): @api.onchange('tempo_limit') def _onchange_tempo_limit(self): for tempo in self: - if tempo.env.user.id not in (7, 688, 28, 377, 12182, 375): + if tempo.env.user.id not in (7, 688, 28, 19, 375): raise UserError("Limit tempo hanya bisa diubah oleh Sales Manager atau Direktur") def button_approve(self): for tempo in self: @@ -381,7 +381,7 @@ class UserPengajuanTempoRequest(models.Model): if tempo.env.user.id in (688, 28, 7): raise UserError("Pengajuan tempo harus di approve oleh sales manager terlebih dahulu") else: - if tempo.env.user.id not in (377, 12182, 375): + if tempo.env.user.id not in (375, 19): # if tempo.env.user.id != 12182: raise UserError("Pengajuan tempo hanya bisa di approve oleh sales manager") else: @@ -400,7 +400,7 @@ class UserPengajuanTempoRequest(models.Model): if tempo.env.user.id == 7: raise UserError("Pengajuan tempo harus di approve oleh Finence terlebih dahulu") else: - if tempo.env.user.id not in (688, 28, 12182): + if tempo.env.user.id not in (688, 28): # if tempo.env.user.id not in (288,28,12182): raise UserError("Pengajuan tempo hanya bisa di approve oleh Finence") else: diff --git a/indoteknik_custom/patch.py b/indoteknik_custom/patch.py new file mode 100644 index 00000000..704ab056 --- /dev/null +++ b/indoteknik_custom/patch.py @@ -0,0 +1,16 @@ +import json, logging +from odoo.http import JsonRequest + +_logger = logging.getLogger(__name__) + +def _safe_jsonloads(self, raw): + """Kembalikan dict kosong bila body kosong / JSON rusak""" + try: + return json.loads(raw) if raw else {} + except Exception as e: + _logger.warning("Bypassed invalid JSON body: %s", e) + return {} + +# Odoo 14 memakai _jsonloads +JsonRequest._jsonloads = _safe_jsonloads +_logger.info("Patch OK → JsonRequest._jsonloads dilindungi (empty JSON diterima)") diff --git a/indoteknik_custom/security/ir.model.access.csv b/indoteknik_custom/security/ir.model.access.csv index 601f04c5..0ac3e86c 100755 --- a/indoteknik_custom/security/ir.model.access.csv +++ b/indoteknik_custom/security/ir.model.access.csv @@ -178,6 +178,17 @@ access_stock_inventory,access.stock.inventory,model_stock_inventory,,1,1,1,1 access_cancel_reason_order,cancel.reason.order,model_cancel_reason_order,,1,1,1,0 access_reject_reason_commision,reject.reason.commision,model_reject_reason_commision,,1,1,1,0 access_shipping_option,shipping.option,model_shipping_option,,1,1,1,1 +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_refund_sale_order,access.refund.sale.order,model_refund_sale_order,base.group_user,1,1,1,1 +access_refund_sale_order_line,access.refund.sale.order.line,model_refund_sale_order_line,base.group_user,1,1,1,1 +access_purchasing_job_seen,purchasing.job.seen,model_purchasing_job_seen,,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 +access_tukar_guling_line_po_all_users,tukar.guling.line.po.all.users,model_tukar_guling_line_po,base.group_user,1,1,1,1 +access_tukar_guling_mapping_koli_all_users,tukar.guling.mapping.koli.all.users,model_tukar_guling_mapping_koli,base.group_user,1,1,1,1
\ No newline at end of file diff --git a/indoteknik_custom/views/account_move.xml b/indoteknik_custom/views/account_move.xml index 46737a40..9b1c791b 100644 --- a/indoteknik_custom/views/account_move.xml +++ b/indoteknik_custom/views/account_move.xml @@ -6,6 +6,12 @@ <field name="model">account.move</field> <field name="inherit_id" ref="account.view_move_form"/> <field name="arch" type="xml"> + <xpath expr="//div[@name='journal_div']" position="after"> + <field name="reklas_misc_id" + string="Journal Entries Reklas" + attrs="{'invisible': [('move_type', '!=', 'in_invoice')]}" + readonly="1"/> + </xpath> <button name="action_register_payment" position="after"> <button name="indoteknik_custom.action_view_invoice_reklas" string="Reklas" type="action" class="btn-primary" attrs="{'invisible': [('state', '!=', 'posted')]}"/> @@ -27,10 +33,16 @@ </field> <field name="invoice_date" position="after"> <field name="sale_id" readonly="1" attrs="{'invisible': [('move_type', '!=', 'out_invoice')]}"/> - <field name="purchase_order_id" readonly="1" attrs="{'invisible': [('move_type', '!=', 'in_invoice')]}"/> + <!-- <field name="purchase_order_id" readonly="1" attrs="{'invisible': [('move_type', '!=', 'in_invoice')]}"/> --> </field> <field name="ref" position="after"> <field name="sale_id" readonly="1" attrs="{'invisible': [('move_type', '!=', 'entry')]}"/> + <!-- <field name="refund_so_links" readonly="1" widget="html" attrs="{'invisible': ['|', ('move_type', '!=', 'entry'), ('has_refund_so', '=', False)]}"/> + <field name="has_refund_so" invisible="1"/> --> + </field> + <field name="reklas_misc_id" position="after"> + <field name="purchase_order_id" context="{'form_view_ref': 'purchase.purchase_order_form'}" options="{'no_create': True}"/> + <field name="bill_id" readonly="1"/> </field> <field name="partner_shipping_id" position="before"> <field name="real_invoice_id" readonly="1" attrs="{'invisible': [('move_type', '!=', 'out_invoice')]}"/> @@ -52,6 +64,7 @@ <field name="due_extension"/> <field name="counter"/> <field name="nomor_kwitansi"/> + <field name="down_payment"/> </field> <field name="to_check" position="after"> <field name="already_paid"/> @@ -59,6 +72,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 37df16ff..514e6284 100644 --- a/indoteknik_custom/views/customer_commision.xml +++ b/indoteknik_custom/views/customer_commision.xml @@ -70,7 +70,7 @@ statusbar_visible="draft,pengajuan1,pengajuan2,pengajuan3,pengajuan4,approved" statusbar_colors='{"reject":"red"}'/> </header> - <sheet string="Customer Commision"> + <sheet string="Customer Benefits"> <div class="oe_button_box" name="button_box"/> <group> <group> @@ -81,6 +81,9 @@ <field name="commision_percent"/> <field name="commision_amt"/> <field name="commision_amt_text"/> + <field name="cashback" attrs="{'invisible': [('commision_type', 'not in', ['cashback'])]}"/> + <field name="total_commision" attrs="{'invisible': [('commision_type', 'not in', ['cashback'])]}"/> + <field name="total_cashback_text" attrs="{'invisible': [('commision_type', 'not in', ['cashback'])]}"/> <field name="grouped_so_number" readonly="1"/> <field name="grouped_invoice_number" readonly="1"/> <field name="approved_by" readonly="1"/> @@ -100,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> @@ -171,7 +175,7 @@ </record> <record id="customer_commision_action" model="ir.actions.act_window"> - <field name="name">Customer Commision</field> + <field name="name">Customer Benefits</field> <field name="type">ir.actions.act_window</field> <field name="res_model">customer.commision</field> <field name="search_view_id" ref="view_customer_commision_filter"/> @@ -179,14 +183,14 @@ </record> <menuitem id="menu_customer_commision_acct" - name="Customer Commision" + name="Customer Benefits" action="customer_commision_action" parent="account.menu_finance_entries" sequence="113" /> <menuitem id="menu_customer_commision_sales" - name="Customer Commision" + name="Customer Benefits" action="customer_commision_action" parent="sale.product_menu_catalog" sequence="101" diff --git a/indoteknik_custom/views/ir_sequence.xml b/indoteknik_custom/views/ir_sequence.xml index 97bf40bb..4915e4c5 100644 --- a/indoteknik_custom/views/ir_sequence.xml +++ b/indoteknik_custom/views/ir_sequence.xml @@ -131,7 +131,7 @@ <field name="number_increment">1</field> </record> - <record id="sequence_commision_customer" model="ir.sequence"> + <!-- <record id="sequence_commision_customer" model="ir.sequence"> <field name="name">Customer Commision</field> <field name="code">customer.commision</field> <field name="active">TRUE</field> @@ -139,6 +139,46 @@ <field name="padding">5</field> <field name="number_next">1</field> <field name="number_increment">1</field> + </record> --> + + <record id="sequence_commision_cashback" model="ir.sequence"> + <field name="name">Customer Commision Cashback</field> + <field name="code">customer.commision.cashback</field> + <field name="prefix">CB/%(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_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> + <field name="code">customer.commision.fee</field> + <field name="prefix">CC/%(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_rebate" model="ir.sequence"> + <field name="name">Customer Commision Rebate</field> + <field name="code">customer.commision.rebate</field> + <field name="prefix">RB/%(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_automatic_purchase" model="ir.sequence"> @@ -160,5 +200,33 @@ <field name="number_next">1</field> <field name="number_increment">1</field> </record> + <record id="seq_tukar_guling" model="ir.sequence"> + <field name="name">Pengajuan Return SO</field> + <field name="code">tukar.guling</field> + <field name="active">TRUE</field> + <field name="prefix">CCM/%(year)s/%(month)s/</field> + <field name="padding">4</field> + <field name="number_next">1</field> + <field name="number_increment">1</field> + </record> + <record id="seq_tukar_guling_po" model="ir.sequence"> + <field name="name">Pengajuan Return PO</field> + <field name="code">tukar.guling.po</field> + <field name="active">TRUE</field> + <field name="prefix">VCM/%(year)s/%(month)s/</field> + <field name="padding">4</field> + <field name="number_next">1</field> + <field name="number_increment">1</field> + </record> + + <record id="seq_refund_sale_order" model="ir.sequence"> + <field name="name">Refund Sale Order</field> + <field name="code">refund.sale.order</field> + <field name="prefix">RC/%(year)s/%(month)s/</field> + <field name="padding">4</field> + <field name="number_next">1</field> + <field name="number_increment">1</field> + <field name="active">True</field> + </record> </data> </odoo>
\ No newline at end of file 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/mrp_production.xml b/indoteknik_custom/views/mrp_production.xml index 3de52a08..5057415f 100644 --- a/indoteknik_custom/views/mrp_production.xml +++ b/indoteknik_custom/views/mrp_production.xml @@ -11,7 +11,7 @@ <field name="bom_id" position="after"> <field name="desc"/> <field name="sale_order"/> - <field name="is_po"/> + <field name="is_po" readonly="1"/> </field> <xpath expr="//form/sheet/notebook/page/field[@name='move_raw_ids']/tree/field[@name='product_uom_qty']" position="before"> <field name="vendor_id"/> diff --git a/indoteknik_custom/views/purchase_order.xml b/indoteknik_custom/views/purchase_order.xml index 0fbbb5e7..ff223125 100755 --- a/indoteknik_custom/views/purchase_order.xml +++ b/indoteknik_custom/views/purchase_order.xml @@ -14,6 +14,29 @@ attrs="{'invisible': ['|', ('sale_order_id', '=', False), ('state', 'not in', ['draft'])]}" /> </div> + <xpath expr="//button[@name='action_view_invoice']" position="before"> + <field name="is_cab_visible" invisible="1"/> + <button type="object" + name="action_view_journal_uangmuka" + class="oe_stat_button" + icon="fa-book" + attrs="{'invisible': [('is_cab_visible', '=', False)]}" + style="width: 200px;"> + <field name="move_id" widget="statinfo" string="Journal Uang Muka"/> + <span class="o_stat_text"> + <t t-esc="record.move_id.name"/> + </span> + </button> + <button type="object" + name="action_view_related_bu" + class="oe_stat_button" + icon="fa-truck" + style="width: 200px;" + attrs="{'invisible': [('state', 'in', ['draft', 'sent'])]}"> + <field name="bu_related_count" widget="statinfo" string="BU Related"/> + </button> + <field name="picking_count" invisible="1"/> + </xpath> <button id="draft_confirm" position="after"> <button name="po_approve" string="Ask Approval" @@ -64,7 +87,9 @@ <field name="payment_term_id"/> <field name="total_cost_service" attrs="{'required': [('partner_id', 'in', [9688, 29712])]}"/> <field name="total_delivery_amt" attrs="{'required': [('partner_id', 'in', [9688, 29712])]}"/> - <field name="product_bom_id"/> + <field name="product_bom_id" attrs="{'invisible': [('product_bom_id', '=', None)]}"/> + <field name="manufacturing_id" attrs="{'invisible': [('product_bom_id', '=', None)]}"/> + <!-- <field name="move_id" domain="[('move_type','=','entry')]" context="{'form_view_ref': 'account.view_move_form'}" options="{'no_create': True}"/> --> </field> <field name="amount_total" position="after"> <field name="total_margin"/> @@ -164,6 +189,13 @@ <field name="order_sales_match_line"/> </page> </xpath> + <xpath expr="//form/sheet/notebook/page[@name='purchase_delivery_invoice']" position="after"> + <page string="Other Info" name="purchase_order_sales_matches_lines"> + <group string="Return Doc"> + <field name="vcm_id"/> + </group> + </page> + </xpath> </field> </record> </data> @@ -308,6 +340,7 @@ <field name="delivery_amt" optional="hide"/> <field name="margin_deduct" optional="hide"/> <field name="hold_outgoing_so" optional="hide"/> + <field name="bu_pick" optional="hide"/> <field name="margin_so"/> </tree> </field> diff --git a/indoteknik_custom/views/purchasing_job.xml b/indoteknik_custom/views/purchasing_job.xml index bb1c7643..e3866d84 100644 --- a/indoteknik_custom/views/purchasing_job.xml +++ b/indoteknik_custom/views/purchasing_job.xml @@ -4,7 +4,7 @@ <field name="name">v.purchasing.job.tree</field> <field name="model">v.purchasing.job</field> <field name="arch" type="xml"> - <tree create="false" multi_edit="1"> + <tree decoration-info="(check_pj == False)" create="false" multi_edit="1"> <field name="product_id"/> <field name="vendor_id"/> <field name="purchase_representative_id"/> @@ -18,6 +18,15 @@ <field name="action"/> <field name="note"/> <field name="date_po"/> + <field name="so_number"/> + <field name="check_pj" invisible="1"/> + <button name="action_open_job_detail" + string="📄" + type="object" + icon="fa-file" + attrs="{'invisible': [('check_pj','=',True)]}" + context="{}"/> + </tree> </field> </record> @@ -41,6 +50,7 @@ <field name="item_code"/> <field name="product"/> <field name="action"/> + <field name="so_number"/> </group> <group> <field name="onhand"/> diff --git a/indoteknik_custom/views/refund_sale_order.xml b/indoteknik_custom/views/refund_sale_order.xml new file mode 100644 index 00000000..4f791722 --- /dev/null +++ b/indoteknik_custom/views/refund_sale_order.xml @@ -0,0 +1,199 @@ +<?xml version="1.0" encoding="UTF-8"?> +<odoo> + <!-- Tree View --> + <record id="view_refund_sale_order_tree" model="ir.ui.view"> + <field name="name">refund.sale.order.tree</field> + <field name="model">refund.sale.order</field> + <field name="arch" type="xml"> + <tree string="Refund Sales Orders"> + <field name="name" readonly="1"/> + <field name="created_date" readonly="1"/> + <field name="partner_id" readonly="1"/> + <field name="sale_order_ids" widget="many2many_tags" readonly="1"/> + <field name="uang_masuk" readonly="1"/> + <field name="ongkir" readonly="1"/> + <field name="total_invoice" readonly="1"/> + <field name="amount_refund" readonly="1"/> + <field name="status" + decoration-info="status == 'draft'" + decoration-danger="status == 'reject'" + decoration-success="status == 'refund'" + decoration-warning="status == 'pengajuan1' or status == 'pengajuan2' or status == 'pengajuan3'" + widget="badge" + readonly="1"/> + <field name="status_payment" + decoration-info="status_payment == 'pending'" + decoration-danger="status_payment == 'reject'" + decoration-success="status_payment == 'done'" + widget="badge" + readonly="1"/> + <field name="refund_date" readonly="1"/> + <field name="amount_refund_text" readonly="1" optional="hide"/> + <field name="invoice_ids" readonly="1" optional="hide"/> + <field name="refund_type" readonly="1" optional="hide"/> + <field name="user_ids" readonly="1" optional="hide"/> + </tree> + </field> + </record> + + <!-- Form View --> + <record id="view_refund_sale_order_form" model="ir.ui.view"> + <field name="name">refund.sale.order.form</field> + <field name="model">refund.sale.order</field> + <field name="arch" type="xml"> + <form string="Refund Sales Order"> + <header> + <button name="action_ask_approval" + type="object" + string="Ask Approval" + attrs="{'invisible': [('status', '!=', 'draft')]}"/> + + <button name="action_approve_flow" + type="object" + string="Approve" + class="oe_highlight" + attrs="{'invisible': [('status', 'in', ['refund', 'reject', 'draft'])]}"/> + <button name="action_trigger_cancel" + type="object" + string="Cancel" + attrs="{'invisible': ['|', ('status_payment', '!=', 'pending'), ('status', '=', 'reject')]}" /> + <button name="action_confirm_refund" + type="object" + string="Confirm Refund" + class="btn-primary" + attrs="{'invisible': ['|', ('status', 'not in', ['pengajuan3','refund']), ('status_payment', '!=', 'pending')]}"/> + <button name="action_create_journal_refund" + string="Journal Refund" + type="object" + class="oe_highlight" + attrs="{'invisible': ['|', ('status', 'not in', ['pengajuan3','refund']), ('journal_refund_state', '=', 'posted')]}"/> + + <field name="status" + widget="statusbar" + statusbar_visible="draft,pengajuan1,pengajuan2,pengajuan3,reject" + attrs="{'invisible': [('status', '!=', 'reject')]}" /> + + <field name="status" + widget="statusbar" + statusbar_visible="draft,pengajuan1,pengajuan2,pengajuan3,refund" + attrs="{'invisible': [('status', '=', 'reject')]}" /> + </header> + <sheet> + <div class="oe_button_box" name="button_box"> + <button name="action_open_journal_refund" + type="object" + class="oe_stat_button" + icon="fa-book" + width="250px" + attrs="{'invisible': ['|', ('journal_refund_move_id', '=', False), ('journal_refund_state', '!=', 'posted')]}"> + <field name="journal_refund_move_id" string="Journal Refund" widget="statinfo"/> + </button> + </div> + <widget name="web_ribbon" + title="PAID" + bg_color="bg-success" + attrs="{'invisible': [('status_payment', '!=', 'done')]}"/> + + <widget name="web_ribbon" + title="CANCEL" + bg_color="bg-danger" + attrs="{'invisible': [('status_payment', '!=', 'reject')]}"/> + <h1> + <field name="name" readonly="1"/> + </h1> + <group col="2"> + <group> + <field name="is_locked" invisible="1"/> + <field name="status_payment" invisible="1"/> + <field name="journal_refund_state" invisible="1"/> + + <field name="partner_id" attrs="{'readonly': [('is_locked', '=', True)]}"/> + <field name="sale_order_ids" widget="many2many_tags" attrs="{'readonly': [('is_locked', '=', True)]}"/> + <field name="invoice_ids" widget="many2many_tags" readonly="1"/> + <field name="invoice_names" widget="html" readonly="1"/> + <field name="so_names" widget="html" readonly="1"/> + <field name="advance_move_names" widget="html" readonly="1"/> + <field name="refund_type" attrs="{'readonly': [('is_locked', '=', True)]}"/> + <field name="note_refund" attrs="{'readonly': [('is_locked', '=', True)]}"/> + </group> + <group> + <field name="uang_masuk" attrs="{'readonly': [('is_locked', '=', True)]}"/> + <field name="total_invoice" attrs="{'readonly': [('is_locked', '=', True)]}"/> + <field name="ongkir" attrs="{'readonly': [('is_locked', '=', True)]}"/> + <field name="amount_refund" attrs="{'readonly': [('is_locked', '=', True)]}"/> + <field name="amount_refund_text" readonly="1"/> + <field name="uang_masuk_type" required="1" attrs="{'readonly': [('is_locked', '=', True)]}"/> + <field name="bukti_uang_masuk_image" widget="image" + attrs="{'invisible': [('uang_masuk_type', '=', 'pdf')], 'readonly': [('is_locked', '=', True)]}"/> + <field name="bukti_uang_masuk_pdf" widget="pdf_viewer" + attrs="{'invisible': [('uang_masuk_type', '=', 'image')], 'readonly': [('is_locked', '=', True)]}"/> + </group> + </group> + + <notebook> + <page string="Produk Line"> + <field name="line_ids" attrs="{'readonly': [('is_locked', '=', True)]}"> + <tree editable="bottom"> + <field name="product_id"/> + <field name="quantity"/> + <field name="reason"/> + </tree> + </field> + </page> + + <page string="Other Info"> + <group col="2"> + <group> + <field name="user_ids" widget="many2many_tags" readonly="1"/> + <field name="created_date" readonly="1"/> + <field name="refund_date" attrs="{'readonly': [('status', 'not in', ['pengajuan3','refund'])]}"/> + </group> + <group> + <field name="bank" attrs="{'readonly': [('is_locked', '=', True)]}"/> + <field name="account_name" attrs="{'readonly': [('is_locked', '=', True)]}"/> + <field name="account_no" attrs="{'readonly': [('is_locked', '=', True)]}"/> + </group> + </group> + </page> + + <page string="Finance Note"> + <group col="2"> + <group> + <field name="finance_note" attrs="{'readonly': [('is_locked', '=', True)]}"/> + </group> + <group> + <field name="bukti_refund_type" reqiured="1" attrs="{'readonly': [('is_locked', '=', True)]}"/> + <field name="bukti_transfer_refund_pdf" widget="pdf_viewer" attrs="{'invisible': [('bukti_refund_type', '=', 'image')]}"/> + <field name="bukti_transfer_refund_image" widget="image" attrs="{'invisible': [('bukti_refund_type', '=', 'pdf')]}"/> + </group> + </group> + </page> + + <page string="Cancel Reason" attrs="{'invisible': [('status', '=', 'refund')]}"> + <group> + <field name="reason_reject"/> + </group> + </page> + </notebook> + </sheet> + <div class="oe_chatter"> + <field name="message_follower_ids" widget="mail_followers"/> + <field name="message_ids" widget="mail_thread"/> + </div> + </form> + </field> + </record> + <!-- Action --> + <record id="action_refund_sale_order" model="ir.actions.act_window"> + <field name="name">Refund Sales Order</field> + <field name="res_model">refund.sale.order</field> + <field name="view_mode">tree,form</field> + </record> + + <!-- Menu --> + <menuitem id="menu_refund_sale_order" + name="Refund" + parent="sale.sale_order_menu" + sequence="10" + action="action_refund_sale_order"/> +</odoo> diff --git a/indoteknik_custom/views/res_partner.xml b/indoteknik_custom/views/res_partner.xml index cb9fa3ac..a030a75c 100644 --- a/indoteknik_custom/views/res_partner.xml +++ b/indoteknik_custom/views/res_partner.xml @@ -65,6 +65,21 @@ <group name="purchase" position="inside"> <field name="leadtime"/> </group> + <xpath expr="//notebook/page[@name='contact_addresses']" position="before"> + <page string="Pin Point Location" name="map_location"> + <group> + <button name="geocode_address" type="object" string="Get Pin Point Location" class="btn btn-primary"/> + </group> + <div style="margin: 16px 0;"> + <field name="map_view" widget="googlemap" nolabel="1"/> + </div> + <group> + <field name="address_map" readonly="1" force_save="1"/> + <field name="latitude" readonly="1" force_save="1"/> + <field name="longtitude" readonly="1" force_save="1"/> + </group> + </page> + </xpath> <field name="vat" position="after"> <field name="email_finance" widget="email"/> <field name="email_sales" widget="email"/> @@ -78,6 +93,15 @@ <field name="main_parent_id" invisible="1" /> <field name="site_id" attrs="{'readonly': [('parent_id', '=', False)]}" domain="[('partner_id', '=', main_parent_id)]" context="{'default_partner_id': active_id}" /> </xpath> + <xpath expr="//field[@name='child_ids']/form/sheet/group" position="after"> + <div class="oe_left" style="margin-top: 16px;"> + <button name="action_open_full_form" + type="object" + string="Detail Information" + class="btn btn-primary" + /> + </div> + </xpath> <xpath expr="//field[@name='property_payment_term_id']" position="attributes"> <attribute name="readonly">0</attribute> </xpath> @@ -189,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/sale_order.xml b/indoteknik_custom/views/sale_order.xml index 0fabf279..c1f1fe61 100755 --- a/indoteknik_custom/views/sale_order.xml +++ b/indoteknik_custom/views/sale_order.xml @@ -35,7 +35,32 @@ string="UangMuka" type="action" attrs="{'invisible': [('approval_status', '!=', 'approved')]}"/> </button> - <field name="payment_term_id" position="after"> + <!-- <xpath expr="//header" position="inside"> + <button name="button_refund" + type="object" + string="Refund" + class="btn-primary" + attrs="{'invisible': ['|', ('state', 'not in', ['sale', 'done']), ('has_refund', '=', True)]}" /> + </xpath> --> + <div class="oe_button_box" name="button_box"> + <field name="advance_payment_move_ids" invisible="1"/> + <button name="action_open_advance_payment_moves" + type="object" + class="oe_stat_button" + icon="fa-book" + attrs="{'invisible': [('advance_payment_move_ids', '=', [])]}"> + <field name="advance_payment_move_count" widget="statinfo" string="Journals"/> + </button> + + <!-- <button type="object" + name="action_view_related_refunds" + class="oe_stat_button" + icon="fa-refresh" + attrs="{'invisible': [('refund_count', '=', 0)]}"> + <field name="refund_count" widget="statinfo" string="Refund"/> + </button> --> + </div> + <field name="payment_term_id" position="after"> <field name="create_uid" invisible="1"/> <field name="create_date" invisible="1"/> <field name="shipping_cost_covered" @@ -103,10 +128,10 @@ <field name="compute_fullfillment" invisible="1" /> </field> <field name="tag_ids" position="after"> - <field name="eta_date_start"/> + <!-- <field name="eta_date_start"/> --> <t t-esc="' to '"/> - <field name="eta_date" readonly="1"/> - <field name="expected_ready_to_ship"/> + <!-- <field name="eta_date" readonly="1"/> --> + <!-- <field name="expected_ready_to_ship"/> --> <field name="ready_to_ship_status_detail"/> <field name="flash_sale"/> <field name="margin_after_delivery_purchase"/> @@ -134,15 +159,29 @@ <field name="real_invoice_id"/> <field name="approval_status"/> <field name="sales_tax_id" - domain="[('type_tax_use','=','sale'), ('active', '=', True)]" required="1"/> - <field name="carrier_id" required="1"/> - <field name="delivery_service_type" readonly="1"/> - <field name="shipping_option_id"/> + domain="[('type_tax_use','=','sale'), ('active', '=', True)]" required="1" /> + <field name="select_shipping_option"/> + <field name="carrier_id" required="1" domain="[]" /> + <field name="delivery_service_type" readonly="1" /> + <field name="shipping_option_id" /> </field> <field name="medium_id" position="after"> <field name="date_doc_kirim" readonly="1"/> <field name="notification" readonly="1"/> </field> + <xpath expr="//page[@name='other_information']/group/group[@name='sale_reporting']" position="after"> + <group string="ETA"> + <field name="et_products"/> + <field name="eta_date_reserved"/> + <field name="expected_ready_to_ship"/> + <field name="eta_date_start"/> + <field name="eta_date" readonly="1"/> + <!-- <field name="has_refund" readonly="1"/> --> + </group> + <group string="Return Doc"> + <field name="ccm_id" readonly="1"/> + </group> + </xpath> <xpath expr="//form/sheet/notebook/page/field[@name='order_line']" position="attributes"> <attribute name="attrs"> @@ -208,6 +247,11 @@ <label for="item_percent_margin"/> <field name="item_percent_margin"/> </div> + <div name="item_percent_margin_before" groups="base.group_no_one" + attrs="{'invisible': [('display_type', '!=', False)]}"> + <label for="item_percent_margin_before"/> + <field name="item_percent_margin_before"/> + </div> </div> <div name="invoice_lines" position="before"> <div name="price_subtotal" groups="base.group_no_one" @@ -239,6 +283,7 @@ attrs="{'readonly': [('parent.approval_status', '!=', False)]}" domain="[('type_tax_use','=','purchase')]" options="{'no_create':True}"/> <field name="item_percent_margin"/> + <field name="item_percent_margin_before"/> <field name="item_margin" optional="hide"/> <field name="margin_md" optional="hide"/> <field name="note" optional="hide"/> @@ -296,10 +341,14 @@ <field name="picking_iu_id"/> <field name="note_ekspedisi"/> </field> + <field name="select_shipping_option" position="attributes"> + <attribute name="attrs"> + {'readonly': [('state', 'in', ['cancel', 'done'])]} + </attribute> + </field> <field name="carrier_id" position="attributes"> <attribute name="attrs"> - {'readonly': [('approval_status', '=', 'approved'), ('state', 'not in', - ['cancel', 'draft'])]} + {'readonly': [('state', 'in', ['cancel', 'done'])]} </attribute> </field> <field name="payment_term_id" position="attributes"> @@ -403,7 +452,7 @@ <field name="state" position="after"> <field name="approval_status"/> <field name="client_order_ref"/> - <field name="notes"/> + <field name="notes" optional="hide"/> <field name="payment_type" optional="hide"/> <field name="payment_status" optional="hide"/> <field name="pareto_status" optional="hide"/> @@ -428,6 +477,7 @@ <field name="payment_type" optional="hide"/> <field name="payment_status" optional="hide"/> <field name="pareto_status" optional="hide"/> + <field name="notes" optional="hide"/> </field> </field> </record> @@ -611,6 +661,16 @@ </record> </data> + <!-- <data> + <record id="sale_order_multi_create_refund_ir_actions_server" model="ir.actions.server"> + <field name="name">Refund</field> + <field name="model_id" ref="sale.model_sale_order"/> + <field name="binding_model_id" ref="sale.model_sale_order"/> + <field name="state">code</field> + <field name="code">action = records.open_form_multi_create_refund()</field> + </record> + </data> --> + <data> <record id="mail_template_sale_order_notification_to_salesperson" model="mail.template"> <field name="name">Sale Order: Notification to Salesperson</field> diff --git a/indoteknik_custom/views/sale_order_delay.xml b/indoteknik_custom/views/sale_order_delay.xml new file mode 100644 index 00000000..b2aad8eb --- /dev/null +++ b/indoteknik_custom/views/sale_order_delay.xml @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="view_sale_order_delay_tree" model="ir.ui.view"> + <field name="name">sale.order.delay.tree</field> + <field name="model">sale.order.delay</field> + <field name="arch" type="xml"> + <tree> + <field name="so_number" /> + <field name="days_delayed" /> + <field name="status" /> + </tree> + </field> + </record> + + <record id="sale_order_delay_action" model="ir.actions.act_window"> + <field name="name">Sale Order Delay</field> + <field name="type">ir.actions.act_window</field> + <field name="res_model">sale.order.delay</field> + <field name="view_mode">tree,form</field> + </record> + + <record id="view_sale_order_delay_form" model="ir.ui.view"> + <field name="name">sale.order.delay.form</field> + <field name="model">sale.order.delay</field> + <field name="arch" type="xml"> + <form> + <sheet> + <group> + <field name="so_number" /> + <field name="days_delayed" /> + <field name="status" /> + </group> + </sheet> + </form> + </field> + </record> + + <menuitem id="menu_sale_order_delay" + name="Sale Order Delay" + action="sale_order_delay_action" + parent="sale.product_menu_catalog" + sequence="8" + /> +</odoo>
\ No newline at end of file diff --git a/indoteknik_custom/views/stock_picking.xml b/indoteknik_custom/views/stock_picking.xml index ae77ab9a..f9200dfa 100644 --- a/indoteknik_custom/views/stock_picking.xml +++ b/indoteknik_custom/views/stock_picking.xml @@ -17,6 +17,7 @@ <field name="driver_arrival_date" optional="hide"/> <field name="note_logistic" optional="hide"/> <field name="note" optional="hide"/> + <field name="sj_return_date" optional="hide"/> <field name="date_reserved" optional="hide"/> <field name="state_reserve" optional="hide"/> <field name="state_packing" widget="badge" decoration-success="state_packing == 'packing_done'" decoration-danger="state_packing == 'not_packing'" optional="hide"/> @@ -49,11 +50,11 @@ type="object" attrs="{'invisible': ['|', ('state', 'in', ['done']), ('approval_receipt_status', '=', 'pengajuan1')]}" /> - <button name="ask_return_approval" - string="Ask Return/Acc" - type="object" - attrs="{'invisible': [('state', 'in', ['draft', 'cancel', 'assigned'])]}" - /> +<!-- <button name="ask_return_approval"--> +<!-- string="Ask Return/Acc"--> +<!-- type="object"--> +<!-- attrs="{'invisible': [('state', 'in', ['draft', 'cancel', 'assigned'])]}"--> +<!-- />--> <button name="action_create_invoice_from_mr" string="Create Bill" type="object" @@ -63,6 +64,12 @@ string="Biteship" type="object" /> + <!-- <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" @@ -91,7 +98,9 @@ /> </button> <field name="backorder_id" position="after"> + <field name="select_shipping_option_so"/> <field name="shipping_method_so_id"/> + <field name="shipping_option_so_id"/> <field name="summary_qty_detail"/> <field name="count_line_detail"/> <field name="dokumen_tanda_terima"/> @@ -183,18 +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"/> diff --git a/indoteknik_custom/views/tukar_guling.xml b/indoteknik_custom/views/tukar_guling.xml new file mode 100644 index 00000000..a79f8b55 --- /dev/null +++ b/indoteknik_custom/views/tukar_guling.xml @@ -0,0 +1,128 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<odoo> + <data> + <!-- Action --> + <record id="action_pengajuan_tukar_guling" model="ir.actions.act_window"> + <field name="name">Pengajuan Return SO</field> + <field name="type">ir.actions.act_window</field> + <field name="res_model">tukar.guling</field> + <field name="view_mode">tree,form</field> + </record> + <!-- Menu --> + <menuitem + id="menu_pengajuan_tukar_guling" + name="Pengajuan Return SO" + parent="sale.sale_order_menu" + sequence="7" + action="action_pengajuan_tukar_guling" + /> + <!-- Tree View --> + <record id="pengajuan_tukar_guling_tree" model="ir.ui.view"> + <field name="name">pengajuan.tukar.guling.tree</field> + <field name="model">tukar.guling</field> + <field name="arch" type="xml"> + <tree create="1" delete="1" default_order="create_date desc"> + <field name="name"/> + <field name="date"/> + <field name="origin" string="SO Number"/> + <field name="operations" string="Operations"/> + <field name="return_type" string="Return Type"/> + <field name="state" widget="badge" + decoration-info="state in ('draft', 'approval_sales', 'approval_finance','approval_logistic')" + decoration-success="state == 'done'" + decoration-muted="state == 'cancel'" + /> + <field name="ba_num" string="Nomor BA"/> + <field name="date_logistic" string="Approved Date"/> + </tree> + </field> + </record> + <!-- Form View --> + <record id="pengajuan_tukar_guling_form" model="ir.ui.view"> + <field name="name">pengajuan.tukar.guling.form</field> + <field name="model">tukar.guling</field> + <field name="arch" type="xml"> + <form> + <header> + <button name="action_submit" string="Submit" type="object" + class="btn-primary" + attrs="{'invisible': [('state', '!=', 'draft')]}"/> + <button name="action_approve" string="Approve" type="object" + class="btn-primary" + attrs="{'invisible': [('state', 'not in', ['approval_sales', 'approval_finance', 'approval_logistic'])]}"/> + <button name="action_cancel" string="Cancel" type="object" + class="btn-secondary" + attrs="{'invisible': [('state', '=', 'cancel')]}"/> + <button name="action_draft" string="Set to Draft" type="object" + class="btn-secondary" + attrs="{'invisible': [('state', '!=', 'cancel')]}"/> + <field name="state" widget="statusbar" readonly="1" + statusbar_visible="draft,approval_sales,approval_logistic,approval_finance,done"/> + </header> + <sheet> + <div class="oe_button_box"> + <button name="action_view_picking" + type="object" + class="oe_stat_button" + icon="fa-truck" + attrs="{'invisible': [('picking_ids', '=', False), ('state', 'in', ['draft', 'approval_sales', 'approval_logistic', 'approval_finance'])]}"> + <field name="picking_ids" widget="statinfo" string="Delivery"/> + </button> + </div> + <div class="oe_title"> + <h1> + <field name="name" readonly="1" class="oe_inline"/> + </h1> + </div> + <group> + <group> + <field name="date" string="Date" readonly="1"/> + <field name="partner_id" readonly="1"/> + <field name="return_type" attrs="{'readonly': [('state', 'not in', 'draft')]}"/> + <field name="operations" + attrs="{'readonly': [('state', 'not in', 'draft')]}"/> + <field name="origin" readonly="1"/> + </group> + <group> + <field name="ba_num" string="Nomor BA"/> + <field name="notes"/> + <field name="date_sales" readonly="1"/> + <field name="date_finance" readonly="1"/> + <field name="date_logistic" readonly="1"/> + </group> + </group> + <notebook> + <page string="Product Lines" name="product_lines"> + <field name="line_ids"> + <tree string="Product Lines" editable="top" create="0" delete="1"> + <field name="sequence" widget="handle"/> + <field name="product_id" required="0" + options="{'no_create': True, 'no_create_edit': True}" readonly="0"/> + <field name="name" force_save="0" readonly="1"/> + <field name="product_uom_qty" string="Quantity"/> + <field name="product_uom" string="UoM" + options="{'no_create': True, 'no_create_edit': True}"/> + </tree> + </field> + </page> + <page string="Mapping Koli" name="mapping_koli"> + <field name="mapping_koli_ids"> + <tree editable="top" create="0" delete="1"> + <field name="pick_id" readonly="1" force_save="1"/> + <field name="product_id" readonly="1" force_save="1"/> + <field name="qty_done" force_save="1" readonly="1"/> + <field name="qty_return"/> + </tree> + </field> + </page> + </notebook> + </sheet> + <div class="oe_chatter"> + <field name="message_follower_ids" widget="mail_followers"/> + <field name="message_ids" widget="mail_thread"/> + </div> + </form> + </field> + </record> + </data> +</odoo>
\ No newline at end of file diff --git a/indoteknik_custom/views/tukar_guling_po.xml b/indoteknik_custom/views/tukar_guling_po.xml new file mode 100644 index 00000000..77feb05f --- /dev/null +++ b/indoteknik_custom/views/tukar_guling_po.xml @@ -0,0 +1,127 @@ +<?xml version="1.0" encoding="UTF-8" ?> +<odoo> + <data> + <!-- Action --> + <record id="action_pengajuan_tukar_guling_po" model="ir.actions.act_window"> + <field name="name">Pengajuan Return PO</field> + <field name="type">ir.actions.act_window</field> + <field name="res_model">tukar.guling.po</field> + <field name="view_mode">tree,form</field> + </record> + <!-- Menu --> + <menuitem + id="menu_pengajuan_tukar_guling_po" + name="Pengajuan Return PO" + parent="purchase.menu_procurement_management" + sequence="3" + action="action_pengajuan_tukar_guling_po" + /> + <!-- Tree View --> + <record id="pengajuan_tukar_guling_po_tree" model="ir.ui.view"> + <field name="name">pengajuan.tukar.guling.po.tree</field> + <field name="model">tukar.guling.po</field> + <field name="arch" type="xml"> + <tree create="1" delete="1" default_order="create_date desc"> + <field name="name"/> + <field name="date"/> + <field name="origin" string="PO Number"/> + <field name="operations" string="Operations"/> + <field name="return_type" string="Return Type"/> + <field name="ba_num" string="Nomor BA"/> + <field name="return_type" string="Return Type"/> + <field name="state" widget="badge" + decoration-info="state in ('draft', 'approval_purchase', 'approval_finance','approval_logistic')" + decoration-success="state == 'done'" + decoration-muted="state == 'cancel'" + /> + <field name="date_logistic" string="Approved Date"/> + </tree> + </field> + </record> + <!-- Form View --> + <record id="pengajuan_tukar_guling_po_form" model="ir.ui.view"> + <field name="name">pengajuan.tukar.guling.po.form</field> + <field name="model">tukar.guling.po</field> + <field name="arch" type="xml"> + <form> + <header> + <button name="action_submit" string="Submit" type="object" + class="btn-primary" + attrs="{'invisible': [('state', '!=', 'draft')]}"/> + <button name="action_approve" string="Approve" type="object" + class="btn-primary" + attrs="{'invisible': [('state', 'not in', ['approval_purchase', 'approval_finance', 'approval_logistic'])]}"/> + <button name="action_cancel" string="Cancel" type="object" + class="btn-secondary" + attrs="{'invisible': [('state', '=', 'cancel')]}" + confirm="Are you sure you want to cancel this record?"/> + <button name="action_draft" string="Set to Draft" type="object" + class="btn-secondary" + attrs="{'invisible': [('state', '!=', 'cancel')]}" + confirm="Are you sure you want to reset this record to draft?"/> + <field name="state" widget="statusbar" readonly="1" + statusbar_visible="draft,approval_purchase,approval_logistic,approval_finance,done"/> + </header> + <sheet> + <div class="oe_button_box"> + <button name="action_view_picking" + type="object" + class="oe_stat_button" + icon="fa-truck" + attrs="{'invisible': [('po_picking_ids', '=', False)]}"> + <field name="po_picking_ids" widget="statinfo" string="Delivery"/> + </button> + </div> + <div class="oe_title"> + <h1> + <field name="name" readonly="1" class="oe_inline"/> + </h1> + </div> + <group> + <group> + <field name="vendor_id" readonly="1"/> + <field name="date" string="Date" readonly="1"/> + <field name="return_type"/> + <!-- <field name="ort_num" readonly="1"/>--> + <!-- <field name="srt_num" readonly="1"/>--> + <field name="operations" string="Operations" + attrs="{ + 'required': [('return_type', 'in', ['revisi_po', 'tukar_guling'])] + }"/> + <field name="origin" readonly="1"/> + <!-- <field name="origin_so" readonly="1"/>--> + </group> + <group> + <field name="ba_num" string="Nomor BA"/> + <field name="notes"/> + <field name="date_purchase" readonly="1"/> + <field name="date_finance" readonly="1"/> + <field name="date_logistic" readonly="1"/> + </group> + </group> + <!-- Product Lines --> + <notebook> + <page string="Product Lines" name="product_lines" create="0" edit="0"> + <field name="line_ids" delete="1" readonly="1"> + <tree string="Product Lines"> + <field name="sequence" widget="handle"/> + <field name="product_id" required="1" + options="{'no_create': True, 'no_create_edit': True}"/> + <field name="name" force_save="1"/> + <field name="product_uom_qty" string="Quantity"/> + <field name="product_uom" string="UoM" + options="{'no_create': True, 'no_create_edit': True}"/> + </tree> + </field> + </page> + </notebook> + </sheet> + <div class="oe_chatter"> + <field name="message_follower_ids" widget="mail_followers"/> + <field name="message_ids" widget="mail_thread"/> + </div> + </form> + </field> + </record> + </data> +</odoo>
\ No newline at end of file diff --git a/indoteknik_custom/views/tukar_guling_return_views.xml b/indoteknik_custom/views/tukar_guling_return_views.xml new file mode 100644 index 00000000..9312005a --- /dev/null +++ b/indoteknik_custom/views/tukar_guling_return_views.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <!-- Inherit the return picking form view --> + <record id="view_stock_return_picking_form_inherit" model="ir.ui.view"> + <field name="name">stock.return.picking.form.inherit.tukar.guling</field> + <field name="model">stock.return.picking</field> + <field name="inherit_id" ref="stock.view_stock_return_picking_form"/> + <field name="priority" eval="20"/> <!-- Higher than stock_account --> + <field name="arch" type="xml"> + <!-- Add fields above the product moves table --> + <xpath expr="//field[@name='product_return_moves']" position="before"> + <div class="row mb-3"> + <div class="col-12"> + <field name="return_type" class="oe_inline"/> + </div> + </div> + </xpath> + </field> + </record> +</odoo>
\ No newline at end of file diff --git a/indoteknik_custom/views/user_pengajuan_tempo.xml b/indoteknik_custom/views/user_pengajuan_tempo.xml index 4eebe9e4..f9dca4ca 100644 --- a/indoteknik_custom/views/user_pengajuan_tempo.xml +++ b/indoteknik_custom/views/user_pengajuan_tempo.xml @@ -204,7 +204,7 @@ <field name="subject">Pengajuan Tempo Harus di Periksa!</field> <field name="email_from">"Indoteknik.com" <noreply@indoteknik.com></field> <field name="reply_to">sales@indoteknik.com</field> - <field name="email_to">vita@indoteknik.co.id</field> + <field name="email_to">putra@indoteknik.co.id,Darren@indoteknik.co.id</field> <!-- <field name="email_to">sapiabon768@gmail.com</field>--> <field name="body_html" type="html"> <table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #F1F1F1; font-family:Inter, Helvetica, Verdana, Arial,sans-serif; line-height: 24px; color: #454748; width: 100%; border-collapse:separate;"> diff --git a/indoteknik_custom/views/x_manufactures.xml b/indoteknik_custom/views/x_manufactures.xml index 01e90a1e..02061251 100755 --- a/indoteknik_custom/views/x_manufactures.xml +++ b/indoteknik_custom/views/x_manufactures.xml @@ -3,7 +3,7 @@ <record id="x_manufactures_action" model="ir.actions.act_window"> <field name="name">Manufacture</field> <field name="res_model">x_manufactures</field> - <field name="view_mode">tree,form</field> + <field name="view_mode">kanban,tree,form</field> <!-- Tambahkan kanban di sini --> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> Add Manufactures! @@ -11,6 +11,44 @@ </field> </record> + <record id="x_manufactures_kanban" model="ir.ui.view"> + <field name="name">Manufactures Kanban</field> + <field name="model">x_manufactures</field> + <field name="arch" type="xml"> + <kanban default_group_by="x_manufacture_level"> + <field name="id"/> <!-- Tambahkan ini --> + <field name="x_name"/> + <field name="x_logo_manufacture"/> + <field name="x_manufacture_level"/> + <templates> + <t t-name="kanban-box"> + <div class="oe_kanban_global_click"> + <div class="o_kanban_image"> + <img t-if="record.x_logo_manufacture.raw_value" + t-att-src="kanban_image('x_manufactures', 'x_logo_manufacture', record.id.raw_value)" + alt="Logo" class="img-fluid" width="64" height="64"/> + <img t-else="" + src="/web/static/src/img/placeholder.png" + alt="No Logo" class="img-fluid" width="64" height="64"/> + </div> + <div class="o_kanban_details"> + <strong><field name="x_name"/></strong> + <div> + <span class="badge badge-secondary"> + <field name="x_manufacture_level"/> + </span> + </div> + <div t-if="record.x_short_desc.raw_value"> + <small><field name="x_short_desc"/></small> + </div> + </div> + </div> + </t> + </templates> + </kanban> + </field> + </record> + <record id="x_manufactures_tree" model="ir.ui.view"> <field name="name">Manufactures</field> <field name="model">x_manufactures</field> |
