summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xindoteknik_custom/__manifest__.py1
-rwxr-xr-xindoteknik_custom/models/__init__.py1
-rwxr-xr-xindoteknik_custom/models/product_template.py31
-rwxr-xr-xindoteknik_custom/models/sale_order.py31
-rw-r--r--indoteknik_custom/models/sourcing_job_order.py1602
-rwxr-xr-xindoteknik_custom/security/ir.model.access.csv10
-rw-r--r--indoteknik_custom/views/ir_sequence.xml9
-rwxr-xr-xindoteknik_custom/views/sale_order.xml14
-rw-r--r--indoteknik_custom/views/sourcing.xml802
9 files changed, 2497 insertions, 4 deletions
diff --git a/indoteknik_custom/__manifest__.py b/indoteknik_custom/__manifest__.py
index 695d5aab..894828df 100755
--- a/indoteknik_custom/__manifest__.py
+++ b/indoteknik_custom/__manifest__.py
@@ -192,6 +192,7 @@
'views/update_depreciation_move_wizard_view.xml',
'views/commission_internal.xml',
'views/keywords.xml',
+ 'views/sourcing.xml',
'views/token_log.xml',
'views/gudang_service.xml',
'views/kartu_stock.xml',
diff --git a/indoteknik_custom/models/__init__.py b/indoteknik_custom/models/__init__.py
index 31ee5108..fb93e0a3 100755
--- a/indoteknik_custom/models/__init__.py
+++ b/indoteknik_custom/models/__init__.py
@@ -168,5 +168,6 @@ from . import commission_internal
from . import gudang_service
from . import update_depreciation_move_wizard
from . import keywords
+from . import sourcing_job_order
from . import token_log
from . import kartu_stock
diff --git a/indoteknik_custom/models/product_template.py b/indoteknik_custom/models/product_template.py
index e10b4de2..ecaf9106 100755
--- a/indoteknik_custom/models/product_template.py
+++ b/indoteknik_custom/models/product_template.py
@@ -91,7 +91,8 @@ class ProductTemplate(models.Model):
group_id = self.env.ref('indoteknik_custom.group_role_merchandiser').id
users_in_group = self.env['res.users'].search([('groups_id', 'in', [group_id])])
active_model = self.env.context.get('active_model')
- if self.env.user.id not in users_in_group.mapped('id') and active_model == None:
+ from_sourcing = self.env.context.get('from_sourcing_approval')
+ if self.env.user.id not in users_in_group.mapped('id') and active_model == None and not from_sourcing:
raise UserError('Hanya MD yang bisa membuat Product')
result = super(ProductTemplate, self).create(vals)
return result
@@ -940,9 +941,32 @@ class ProductProduct(models.Model):
qr_code_variant = fields.Binary("QR Code Variant", compute='_compute_qr_code_variant')
qty_pcs_box = fields.Float("Pcs Box")
barcode_box = fields.Char("Barcode Box")
- # keyword_id = fields.Many2one('keywords', string='Keyword')
+ keyword_id = fields.Many2one('keywords', string='Keyword')
+ sourcing_job_id = fields.Many2one(
+ "sourcing.job.order",
+ string="Sourcing Job",
+ readonly=True,
+ )
has_magento = fields.Boolean(string='Has Magento?', default=False, readonly=True)
+ def _add_product_to_keywords(self,product):
+ keywords_model = self.env['keywords']
+ if not product:
+ return False
+
+ for product in self:
+ match_keywords = keywords_model.search([
+ '|',
+ ('name', 'ilike', product.name),
+ ('keywords', 'ilike', product.website_description)
+ ])
+
+ for kw in match_keywords.filtered(lambda k: not k.skip):
+ if not self.unpublished and product.id not in kw.product_ids.ids:
+ kw.write({'product_ids': [(4, product.id)]})
+
+ return True
+
def generate_product_sla(self):
product_variant_ids = self.env.context.get('active_ids', [])
product_variant = self.search([('id', 'in', product_variant_ids)])
@@ -960,7 +984,8 @@ class ProductProduct(models.Model):
group_id = self.env.ref('indoteknik_custom.group_role_merchandiser').id
active_model = self.env.context.get('active_model')
users_in_group = self.env['res.users'].search([('groups_id', 'in', [group_id])])
- if self.env.user.id not in users_in_group.mapped('id') and active_model == None:
+ from_sourcing = self.env.context.get('from_sourcing_approval')
+ if self.env.user.id not in users_in_group.mapped('id') and active_model == None and not from_sourcing:
raise UserError('Hanya MD yang bisa membuat Product')
result = super(ProductProduct, self).create(vals)
return result
diff --git a/indoteknik_custom/models/sale_order.py b/indoteknik_custom/models/sale_order.py
index 2b064042..042cd483 100755
--- a/indoteknik_custom/models/sale_order.py
+++ b/indoteknik_custom/models/sale_order.py
@@ -405,6 +405,7 @@ class SaleOrder(models.Model):
client_order_ref = fields.Char(tracking=True)
+ sourcing_job_count = fields.Integer(string='Sourcing Count', compute='_compute_sourcing_count')
forecast_raw = fields.Text(
string='Forecast Raw',
compute='_compute_forecast_raw'
@@ -3931,6 +3932,17 @@ class SaleOrder(models.Model):
'context': {'default_sale_order_ids': [self.id]},
}
+ def action_view_related_sjo(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Sourcing Job',
+ 'res_model': 'sourcing.job.order',
+ 'view_mode': 'tree,form',
+ 'domain': [('so_id', '=', self.id)],
+ 'context': {'default_so_id': self.id},
+ }
+
def _compute_refund_ids(self):
for order in self:
refunds = self.env['refund.sale.order'].search([
@@ -3944,6 +3956,12 @@ class SaleOrder(models.Model):
('sale_order_ids', 'in', order.id)
])
+ def _compute_sourcing_count(self):
+ for order in self:
+ order.sourcing_job_count = self.env['sourcing.job.order'].search_count([
+ ('so_id', '=', order.id)
+ ])
+
@api.depends('invoice_ids')
def _compute_advance_payment_move(self):
for order in self:
@@ -3989,6 +4007,17 @@ class SaleOrder(models.Model):
'target': 'current',
}
+ def action_open_sjo(self):
+ return {
+ 'name': 'SJO',
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'sourcing.job.order',
+ 'view_mode': 'form',
+ 'target': 'current',
+ 'context': {
+ 'default_so_id': self.id,
+ }
+ }
class SaleForecastCoverage(models.Model):
_name = 'sale.forecast.coverage'
_description = 'Sale Forecast Coverage'
@@ -4009,4 +4038,4 @@ class SaleForecastCoverage(models.Model):
reservation = fields.Boolean()
is_late = fields.Boolean()
- replenishment_filled = fields.Boolean() \ No newline at end of file
+ replenishment_filled = fields.Boolean()
diff --git a/indoteknik_custom/models/sourcing_job_order.py b/indoteknik_custom/models/sourcing_job_order.py
new file mode 100644
index 00000000..ce0bc4ec
--- /dev/null
+++ b/indoteknik_custom/models/sourcing_job_order.py
@@ -0,0 +1,1602 @@
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError
+from datetime import date, datetime
+import requests
+import logging
+import pytz
+from pytz import timezone
+import base64
+import xlrd, xlwt
+import io
+
+_logger = logging.getLogger(__name__)
+
+
+class SourcingJobOrder(models.Model):
+ _name = 'sourcing.job.order'
+ _description = 'Sourcing Job Order MD'
+ _rec_name = 'name'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+ _order = 'is_priority desc, state asc, create_date desc'
+
+ name = fields.Char(string='Job Number', default='New', copy=False, readonly=True)
+ leads_id = fields.Many2one('crm.lead', string='Leads Number')
+ md_user_id = fields.Many2many('res.users', string='MD Persons', compute="_compute_md_persons")
+ so_id = fields.Many2one('sale.order', string='SO Number', tracking=True)
+ state = fields.Selection([
+ ('draft', 'Untaken'),
+ ('taken', 'On Sourcing'),
+ ('partial', 'Partial Complete'),
+ ('done', 'Complete'),
+ ('cancel', 'Cancelled')
+ ], string='Status', default='draft', tracking=True)
+ approval_sales = fields.Selection([
+ ('draft', 'Requested'),
+ ('approve', 'Approved'),
+ ('reject', 'Rejected'),
+ ], string='Approval Sales', tracking=True)
+ takeover_request = fields.Many2one(
+ 'res.users',
+ string='Takeover Requested By',
+ readonly=True,
+ tracking=True,
+ help='MD yang meminta takeover'
+ )
+ is_priority = fields.Boolean(
+ string="Priority",
+ default=False,
+ tracking=True,
+ help="Otomatis aktif jika request approval ditolak oleh sales."
+ )
+ eta_sales = fields.Date(string='Expected Ready')
+ eta_complete = fields.Date(string='Completed Date')
+ cancel_reason = fields.Text(string="Reason for Cancel", tracking=True)
+ line_ids = fields.One2many('sourcing.job.order.line', 'order_id', string='Products')
+ line_sales_input_ids = fields.One2many(
+ 'sourcing.job.order.line', 'order_id',
+ string='Sales Input Lines',
+ )
+ line_sales_view_ids = fields.One2many(
+ 'sourcing.job.order.line', 'order_id',
+ string='Sales View Lines',
+ )
+ converted_product_ids = fields.One2many(
+ "product.product",
+ "sourcing_job_id",
+ string="Converted Products",
+ readonly=True,
+ )
+ converted_product_count = fields.Integer(
+ compute="_compute_converted_product_count",
+ string="Converted Product Count",
+ )
+ progress_status = fields.Char(
+ string='Progress Status',
+ compute='_compute_progress_status',
+ default=''
+ )
+
+ has_price_in_lines = fields.Boolean(
+ string='Has Line with Price',
+ compute='_compute_has_price_in_lines',
+ )
+
+ def _get_jakarta_today(self):
+ jakarta_tz = pytz.timezone('Asia/Jakarta')
+ now_jakarta = datetime.now(jakarta_tz)
+ return now_jakarta.date()
+
+ @api.depends('eta_sales', 'eta_complete', 'create_date', 'state')
+ def _compute_progress_status(self):
+ for rec in self:
+
+ if rec.state == 'cancel':
+ rec.progress_status = 'โšซ Cancelled'
+ continue
+
+ if rec.state == 'done':
+ if rec.eta_sales and rec.eta_complete:
+ delta = (rec.eta_complete - rec.eta_sales).days
+ if delta < 0:
+ rec.progress_status = f'๐ŸŸข Early {abs(delta)} hari'
+ elif delta == 0:
+ rec.progress_status = '๐Ÿ”ต Ontime'
+ else:
+ rec.progress_status = f'๐Ÿ”ด Delay {delta} hari'
+ elif rec.create_date and rec.eta_complete:
+ durasi = (rec.eta_complete - rec.create_date.date()).days
+ rec.progress_status = f'โœ… Selesai dalam {durasi} hari'
+ else:
+ rec.progress_status = 'โœ… Selesai'
+ continue
+
+ if rec.state in ['taken', 'partial', 'draft']:
+ rec.progress_status = '๐ŸŸก On Track'
+
+ @api.depends('line_ids.price', 'line_ids.vendor_id')
+ def _compute_has_price_in_lines(self):
+ for rec in self:
+ # Cek apakah ada minimal satu line yang sudah punya price > 0 dan vendor_id
+ has_price = any(
+ (line.price and line.price > 0 and line.vendor_id)
+ for line in rec.line_ids
+ )
+ rec.has_price_in_lines = bool(has_price)
+
+ @api.depends('line_ids.md_person_ids')
+ def _compute_md_persons(self):
+ for rec in self:
+ md_users = rec.line_ids.mapped('md_person_ids').filtered(lambda x: x)
+ rec.md_user_id = [(6, 0, md_users.ids)]
+
+ @api.depends("converted_product_ids")
+ def _compute_converted_product_count(self):
+ for rec in self:
+ rec.converted_product_count = len(rec.converted_product_ids)
+
+ @api.model
+ def create(self, vals):
+ """Hanya Sales & Merchandiser yang boleh membuat job."""
+ if not (self.env.user.has_group('indoteknik_custom.group_role_sales') or
+ self.env.user.has_group('indoteknik_custom.group_role_merchandiser')):
+ raise UserError("โŒ Hanya Sales dan Merchandiser yang boleh membuat Sourcing Job.")
+
+ if vals.get('name', 'New') == 'New':
+ vals['name'] = self.env['ir.sequence'].next_by_code('sourcing.job.order') or 'New'
+
+ rec = super().create(vals)
+
+ return rec
+
+
+ def write(self, vals):
+ if self.env.uid != self.create_uid.id and not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
+ raise UserError("โŒ Hanya Sales dan Merchandiser yang boleh mengedit Sourcing Job.")
+
+ old_data = {}
+ for rec in self:
+ old_data[rec.id] = {
+ 'state': rec.state,
+ 'approval_sales': rec.approval_sales,
+ 'line_data': {
+ line.id: {
+ 'state': line.state,
+ 'vendor_id': line.vendor_id.id if line.vendor_id else False,
+ 'price': line.price,
+ }
+ for line in rec.line_ids
+ },
+ }
+
+ res = super().write(vals)
+ if vals.get('product_assets'):
+ for rec in self:
+ rec._log_product_assets_upload()
+
+ for rec in self:
+ changes = []
+ old = old_data.get(rec.id, {})
+
+ if old.get('state') != rec.state:
+ changes.append(f"State: <b>{old.get('state')}</b> โ†’ <b>{rec.state}</b>")
+ if old.get('approval_sales') != rec.approval_sales:
+ changes.append(f"Approval Status: <b>{old.get('approval_sales')}</b> โ†’ <b>{rec.approval_sales}</b>")
+
+ old_lines = old.get('line_data', {})
+ for line in rec.line_ids:
+ old_line = old_lines.get(line.id)
+ if not old_line:
+ continue
+
+ if (
+ old_line['vendor_id'] != (line.vendor_id.id if line.vendor_id else False)
+ and old_line['price'] == line.price
+ ):
+ raise UserError(
+ f"โš ๏ธ Harga untuk produk {line.product_name} belum diperbarui setelah mengganti Vendor."
+ )
+
+ sub_changes = []
+
+ if old_line['state'] != line.state:
+ sub_changes.append(f"- state: <b>{old_line['state']}</b> โ†’ <b>{line.state}</b>")
+
+ if old_line['vendor_id'] != (line.vendor_id.id if line.vendor_id else False):
+ old_vendor = self.env['res.partner'].browse(old_line['vendor_id']).name if old_line['vendor_id'] else '-'
+ sub_changes.append(f"- vendor: <b>{old_vendor}</b> โ†’ <b>{line.vendor_id.name if line.vendor_id else '-'}</b>")
+
+ if old_line['price'] != line.price:
+ sub_changes.append(f"- price: <b>{old_line['price']}</b> โ†’ <b>{line.price}</b>")
+
+ if sub_changes:
+ joined = "<br/>".join(sub_changes)
+ changes.append(f"<b>{line.product_name}</b>:<br/>{joined}")
+
+ if changes:
+ message = "<br/><br/>".join(changes)
+ rec.message_post(
+ body=f"Perubahan pada Sourcing Job:<br/>{message}",
+ subtype_xmlid="mail.mt_comment",
+ )
+
+ return res
+
+ def action_cancel(self):
+ for rec in self:
+ if not self.env.user.has_group('indoteknik_custom.group_role_sales'):
+ raise UserError("โŒ Hanya Sales yang dapat mengcancel Sourcing Job.")
+
+ if not rec.cancel_reason:
+ raise UserError("โš ๏ธ Isi alasan pembatalan terlebih dahulu.")
+
+ rec.write({'state': 'cancel'})
+ rec.message_post(body=("Job <b>%s</b> dibatalkan oleh %s<br/>Alasan: %s") %
+ (rec.name, self.env.user.name, rec.cancel_reason))
+
+ def action_open_converted_products(self):
+ """Open converted products related to this SJO."""
+ self.ensure_one()
+ return {
+ 'name': 'Converted Products',
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'tree,form',
+ 'res_model': 'product.product',
+ 'domain': [('id', 'in', self.converted_product_ids.ids)],
+ 'context': {'default_sourcing_job_id': self.id},
+ }
+
+ def action_open_export_wizard(self):
+ self.ensure_one()
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Export Produk ke SO',
+ 'res_model': 'wizard.export.sjo.to.so',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'default_sjo_id': self.id,
+ }
+ }
+
+
+class SourcingJobOrderLine(models.Model):
+ _name = 'sourcing.job.order.line'
+ _description = 'Sourcing Job Order Line'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+
+ order_id = fields.Many2one('sourcing.job.order', string='Job Order', ondelete='cascade')
+ product_id = fields.Many2one('product.product', string='Product', ondelete='cascade')
+ md_person_ids = fields.Many2one('res.users', string='MD Person', ondelete='cascade')
+ brand_id = fields.Many2one('x_manufactures', string='Manufactures', ondelete='cascade')
+ so_id = fields.Many2one('sale.order', string='SO Number', tracking=True)
+ product_name_md = fields.Char(string='Nama Barang')
+ descriptions_md = fields.Text(string='Deskripsi Barang')
+
+ product_name = fields.Char(string='Nama Barang')
+ brand = fields.Char(string='Brand')
+ code = fields.Char(string='SKU')
+ budget = fields.Char(string='Expected Price')
+ note = fields.Text(string='Note Sourcing')
+ attachment_type = fields.Selection([
+ ('none', 'None'),
+ ('pdf', '.PDF'),
+ ('img', '.IMG'),
+ ('other', 'Lainnya'),
+ ], default='none')
+ product_attachment_pdf = fields.Binary(string="Product Attachment")
+ product_attachment_img = fields.Binary(string="Product Attachment")
+ product_attachment_other = fields.Binary(string="Product Attachment")
+ product_attachment_filename = fields.Char(string="Filename")
+
+ descriptions = fields.Text(string='Deskripsi / Spesifikasi')
+ reason = fields.Text(string='Reason Unavailable')
+ sla = fields.Char(string='SLA Product')
+ quantity = fields.Float(string='Quantity Product', required=True)
+ price = fields.Float(string='Purchase Price')
+ now_price = fields.Float(string='Current Purchase Price', readonly=True)
+ last_updated_price = fields.Datetime(string='Last Update Price', readonly=True)
+ tax_id = fields.Many2one('account.tax', string='Tax', domain=[('active', '=', True), ('type_tax_use', '=', 'purchase')])
+ vendor_id = fields.Many2one('res.partner', string="Vendor")
+ product_category = fields.Many2one('product.category', string="Product Category")
+ product_class = fields.Many2many('product.public.category', string="Categories")
+ exported_to_so = fields.Boolean(string="Exported to SO", default=False)
+ state = fields.Selection([
+ ('draft', 'Unsource'),
+ ('sourcing', 'On Sourcing'),
+ ('sent', 'Approval Sent'),
+ ('approve', 'Done Sourcing'),
+ ('cancel', 'Unavailable')
+ ], default='draft', tracking=True)
+ product_type = fields.Selection([
+ ('consu', 'Consumable'),
+ ('servis', 'Service'),
+ ('product', 'Storable Product'),
+ ], default='product')
+ subtotal = fields.Float(string='Subtotal', compute='_compute_subtotal')
+ show_for_sales = fields.Boolean(
+ string="Show for Sales",
+ compute="_compute_show_for_sales",
+ )
+ show_salesperson = fields.Many2one(
+ 'res.users',
+ string="Salesperson",
+ )
+
+ so_state = fields.Selection(
+ [
+ ('draft', 'Quotation'),
+ ('cancel', 'Cancel'),
+ ('sale', 'Sale Order')
+ ],
+ string="Status SO",
+ compute="_compute_so_data"
+ )
+
+ so_name = fields.Char(
+ string="SO Number",
+ compute="_compute_so_data"
+ )
+ is_md_person = fields.Boolean(
+ string="Is MD Person",
+ compute="_compute_is_md_person"
+ )
+ is_receiver = fields.Boolean(
+ string="Is MD Receiver",
+ compute="_compute_is_md_person"
+ )
+
+ is_given = fields.Boolean(string='Is Given', tracking=True)
+ given_to_id = fields.Many2one('res.users', string='Given To')
+ previous_md_id = fields.Many2one('res.users', string='Previous MD')
+
+ @api.depends('quantity', 'price', 'tax_id')
+ def _compute_subtotal(self):
+ for line in self:
+ subtotal = (line.quantity or 0.0) * (line.price or 0.0)
+
+ if line.tax_id:
+ tax = line.tax_id.amount / 100
+
+ if line.tax_id.price_include:
+ subtotal = subtotal / (1 + tax)
+
+ line.subtotal = subtotal
+
+ @api.depends('order_id.so_id.user_id', 'order_id.so_id.state', 'order_id.so_id.name')
+ def _compute_so_data(self):
+ for rec in self:
+ so = rec.order_id.so_id
+ if so:
+ rec.so_state = so.state if so.state in ['draft', 'sale'] else False
+ rec.so_name = so.name
+ else:
+ rec.so_state = False
+ rec.so_name = False
+
+ def _compute_is_md_person(self):
+ current_user = self.env.user
+ for rec in self:
+ rec.is_md_person = bool(rec.md_person_ids == current_user)
+ rec.is_receiver = bool(rec.given_to_id == current_user)
+
+ @api.constrains('product_type', 'product_category', 'product_class')
+ def _check_required_fields_for_md(self):
+ for rec in self:
+ if rec.state == 'cancel':
+ continue
+ is_md = self.env.user.has_group('indoteknik_custom.group_role_merchandiser')
+ if is_md and (not rec.product_type or rec.product_category == False or rec.product_class == False):
+ raise UserError("MD wajib mengisi SKU, Product Type, Product Category, dan Categories!")
+
+ @api.depends('price', 'vendor_id', 'order_id')
+ def _compute_show_for_sales(self):
+ for rec in self:
+ rec.show_for_sales = bool(
+ rec.order_id and rec.price not in (None, 0) and rec.vendor_id
+ )
+
+ @api.model
+ def create(self, vals):
+ order_id = vals.get('order_id')
+ if order_id:
+ order = self.env['sourcing.job.order'].browse(order_id)
+ if order.state == 'taken' and order.line_ids.md_person_ids != self.env.user:
+ raise UserError("โŒ SJO sudah taken. Tidak boleh tambah line.")
+ if order.so_id:
+ vals['so_id'] = order.so_id.id
+ vals['show_salesperson'] = order.so_id.user_id.id
+
+ rec = super().create(vals)
+ return rec
+
+ def write(self, vals):
+ for rec in self:
+ if (
+ rec.md_person_ids
+ and self.env.uid != rec.md_person_ids.id
+ and rec.order_id.create_uid != self.env.user
+ ):
+ raise UserError("โŒ Hanya MD yang memegang job yang boleh mengedit Sourcing Job.")
+
+ res = super().write(vals)
+ if 'state' in vals:
+ self._update_parent_state()
+ return res
+
+ def _update_parent_state(self):
+ for rec in self:
+ order = rec.order_id
+ if not order:
+ continue
+
+ lines = order.line_ids
+ if not lines:
+ continue
+
+ total = len(lines)
+
+ if total == 1:
+ line = lines[0]
+ if line.state == 'approve':
+ order.state = 'done'
+ elif line.state == 'cancel':
+ order.state = 'cancel'
+ else:
+ order.state = 'taken'
+ continue
+
+ states = lines.mapped('state')
+
+ all_cancel = all(s == 'cancel' for s in states)
+ all_done_or_cancel = all(s in ['approve', 'cancel'] for s in states)
+ any_done = any(s == 'approve' for s in states)
+ any_progress = any(s not in ['approve', 'cancel', 'draft'] for s in states)
+
+ if all_cancel:
+ order.state = 'cancel'
+ continue
+
+ if all_done_or_cancel:
+ order.state = 'done'
+ continue
+
+ if any_done and not all_done_or_cancel:
+ order.state = 'partial'
+ continue
+
+ if any_progress:
+ order.state = 'taken'
+ continue
+
+ def action_take(self):
+ for rec in self:
+ if not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
+ raise UserError("โŒ Hanya Merchandiser yang dapat mengambil Sourcing Job.")
+ if rec.state != 'draft':
+ continue
+ rec.state = 'sourcing'
+ rec.md_person_ids = self.env.uid
+ rec.order_id.state = 'taken'
+
+ line_no = 1
+ if rec.order_id:
+ all_lines = self.search(
+ [('order_id', '=', rec.order_id.id)],
+ order='id asc'
+ )
+ for i, r in enumerate(all_lines, start=1):
+ if r.id == rec.id:
+ line_no = i
+ break
+
+ rec.message_post(
+ body=("Line <b>%s</b> dari Order <b>%s</b> diambil oleh %s")
+ % (line_no, rec.order_id.name or '-', self.env.user.name)
+ )
+
+ def action_multi_take(self):
+ if not self.env.user.has_group('indoteknik_custom.group_role_merchandiser'):
+ raise UserError("โŒ Hanya Merchandiser yang dapat mengambil Sourcing Job.")
+
+ unsource = self.filtered(lambda r: r.state == 'draft')
+ if not unsource:
+ raise UserError("Tidak ada record Unsource untuk diambil.")
+
+ unsource.write({
+ 'state': 'sourcing',
+ 'md_person_ids': self.env.uid
+ })
+
+ for rec in unsource:
+ if rec.order_id.state == 'draft':
+ rec.order_id.state = 'taken'
+
+ line_no = self.search_count([
+ ('order_id', '=', rec.order_id.id),
+ ('id', '<=', rec.id)
+ ])
+
+ rec.message_post(
+ body=("Line <b>%s</b> dari Order <b>%s</b> diambil oleh %s")
+ % (line_no, rec.order_id.name or '-', self.env.user.name)
+ )
+
+ if rec.order_id.state != 'draft':
+ continue
+
+ def action_multi_ask_approval(self):
+ bot_sjo = '8335015210:AAGbObP0jQf7ptyqJhYdBYn5Rm0CWOd_yIM'
+ chat_sjo = '6076436058'
+ api_base = f'https://api.telegram.org/bot{bot_sjo}/sendMessage'
+
+ order_ids = self.mapped('order_id')
+ if len(order_ids) != 1:
+ raise UserError("โŒ Semua line harus berasal dari Sourcing Job yang sama.")
+
+ order_ids = self.mapped('order_id')
+ if len(order_ids) != 1:
+ raise UserError("โŒ Semua line harus berasal dari Sourcing Job yang sama.")
+
+ job = order_ids[0]
+
+ md_users = self.mapped('md_person_ids')
+ if len(md_users) != 1 or md_users[0] != self.env.user:
+ raise UserError("โŒ Hanya MD yang memegang semua line ini yang bisa request approval.")
+
+ for line in self:
+ if line.state != 'sourcing':
+ raise UserError(f"โš ๏ธ Produk '{line.product_name_md}' bukan status Sourcing.")
+
+ if (
+ not line.vendor_id
+ or not line.product_name_md
+ or not brand_id
+ or not line.price or line.price <= 0
+ or not line.tax_id
+ or not line.subtotal or line.subtotal <= 0
+ or not line.product_type
+ or not line.product_category
+ or not line.product_class
+ ):
+ raise UserError(f"โŒ Data produk '{line.product_name_md}' belum lengkap.")
+
+ activity_type = self.env.ref('mail.mail_activity_data_todo')
+
+ approved_lines_text = ""
+
+ for line in self:
+ line.state = 'sent'
+
+ line.activity_schedule(
+ activity_type_id=activity_type.id,
+ user_id=job.create_uid.id,
+ note=f"{self.env.user.name} meminta approval untuk produk '{line.product_name_md}' di SJO '{job.name}'.",
+ )
+
+ approved_lines_text += f"<li>{line.product_name_md} - {line.price or 0}</li>"
+
+ line.message_post(
+ body=f"๐Ÿ“ค <b>Request approval dikirim (Multi)</b>",
+ subtype_xmlid="mail.mt_comment",
+ )
+
+ job.message_post(
+ body=(
+ f"๐Ÿ“ค <b>Multi Request Approval</b><br/>"
+ f"<ul>{approved_lines_text}</ul>"
+ f"<b>MD:</b> {self.env.user.name}"
+ ),
+ subtype_xmlid="mail.mt_comment",
+ )
+
+ self.env.user.notify_success(
+ message=f"{len(self)} produk berhasil dikirim untuk approval.",
+ title="Multi Request Sent"
+ )
+
+ # return {'type': 'ir.actions.client', 'tag': 'reload'}
+
+
+ def action_ask_approval(self):
+ bot_sjo = '8335015210:AAGbObP0jQf7ptyqJhYdBYn5Rm0CWOd_yIM'
+ chat_sjo = '6076436058'
+ api_base = f'https://api.telegram.org/bot{bot_sjo}/sendMessage'
+
+ for line in self:
+ job = line.order_id
+
+ if line.md_person_ids != self.env.user:
+ raise UserError("โŒ Hanya MD pada line ini yang dapat Request Approval.")
+
+ if line.state != 'sourcing':
+ raise UserError("โš ๏ธ Hanya line status 'Sourcing' yang bisa minta approval.")
+
+ missing_fields = []
+
+ if not line.vendor_id:
+ missing_fields.append("Vendor")
+
+ if not line.product_name_md:
+ missing_fields.append("Product Name")
+
+ if not line.brand_id:
+ missing_fields.append("Manufactures")
+
+ if not line.price or line.price <= 0:
+ missing_fields.append("Price")
+
+ if not line.tax_id:
+ missing_fields.append("Tax")
+
+ if not line.subtotal or line.subtotal <= 0:
+ missing_fields.append("Subtotal")
+
+ if not line.product_type:
+ missing_fields.append("Product Type")
+
+ if not line.product_category:
+ missing_fields.append("Product Category")
+
+ if not line.product_class:
+ missing_fields.append("Product Class")
+
+ if missing_fields:
+ raise UserError(
+ "โŒ Lengkapi data berikut sebelum Ask Approval Sales:\n- " +
+ "\n- ".join(missing_fields)
+ )
+
+ line.state = 'sent'
+
+ activity_type = self.env.ref('mail.mail_activity_data_todo')
+
+ line.activity_schedule(
+ activity_type_id=activity_type.id,
+ user_id=job.create_uid.id,
+ note=f"{self.env.user.name} meminta approval untuk produk '{line.product_name_md}' di SJO '{job.name}'.",
+ )
+
+ line.message_post(
+ body=(
+ f"๐Ÿ“ค <b>Request approval dikirim</b><br/>"
+ f"Kepada: <b>{job.create_uid.name}</b><br/>"
+ f"Produk: <b>{line.product_name_md}</b>"
+ ),
+ subtype_xmlid="mail.mt_comment"
+ )
+
+ job.message_post(
+ body=(
+ f"๐Ÿ“ค <b>Request approval line</b><br/>"
+ f"<ul>"
+ f"<li><b>Produk:</b> {line.product_name_md}</li>"
+ f"<li><b>MD:</b> {self.env.user.name}</li>"
+ f"<li><b>Vendor:</b> {line.vendor_id.display_name if line.vendor_id else '-'}</li>"
+ f"<li><b>Harga:</b> {line.price or 0}</li>"
+ f"</ul>"
+ ),
+ subtype_xmlid="mail.mt_comment"
+ )
+
+ self.env.user.notify_success(
+ message=f"Request approval untuk '{line.product_name_md}' dikirim ke {job.create_uid.name}",
+ title="Request Sent",
+ )
+
+ base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+ url = f"{base_url}/web#id={job.id}&model=sourcing.job.order&view_type=form"
+
+ try:
+ msg_text = (
+ f"๐Ÿ“ข <b>Request Approval Produk</b>\n\n"
+ f"๐Ÿงพ <b>Sourcing Job:</b> <a href='{url}'>๐Ÿ“Ž {job.name}</a>\n"
+ f"๐Ÿ“ฆ <b>Produk:</b> {line.product_name_md}\n"
+ f"๐Ÿ‘ค <b>MD:</b> {self.env.user.name}\n"
+ f"๐Ÿ’ฐ <b>Harga:</b> {line.price or 0}\n"
+ f"๐Ÿ“… <b>Tanggal:</b> {fields.Datetime.now().strftime('%d-%m-%Y %H:%M')}\n\n"
+ f"Silakan review di Odoo."
+ )
+
+ payload = {
+ 'chat_id': chat_sjo,
+ 'text': msg_text,
+ 'parse_mode': 'HTML'
+ }
+
+ response = requests.post(api_base, data=payload, timeout=10)
+ response.raise_for_status()
+
+ except Exception as e:
+ _logger.warning(f"Gagal kirim telegram approval line: {e}")
+
+ return {'type': 'ir.actions.client', 'tag': 'reload'}
+
+ def action_approve_approval(self):
+ ProductProduct = self.env['product.product']
+ PurchasePricelist = self.env['purchase.pricelist']
+ SaleOrderLine = self.env['sale.order.line']
+
+ for rec in self:
+ job = rec.order_id
+
+ if job.create_uid != self.env.user:
+ raise UserError("โŒ Hanya pembuat Sourcing Job yang bisa approve.")
+
+ rec.write({'state': 'approve'})
+
+ product = False
+ if rec.code:
+ product = ProductProduct.search([
+ ('default_code', '=', rec.code),
+ ('active', '=', True)
+ ], limit=1)
+
+ if product:
+ rec.product_id = product.id
+
+ self.env.user.notify_warning(
+ message=f"SKU {rec.code} sudah ada. Tidak dibuat ulang.",
+ title="SKU Exists"
+ )
+
+ else:
+ type_map = {
+ 'servis': 'service',
+ 'product': 'product',
+ 'consu': 'consu',
+ }
+
+ product = ProductProduct.with_context(from_sourcing_approval=True).create({
+ 'name': rec.product_name_md,
+ 'default_code': rec.code or False,
+ 'description': rec.descriptions_md or '',
+ 'type': type_map.get(rec.product_type, 'product'),
+ 'categ_id': rec.product_category.id if rec.product_category else False,
+ 'x_manufacture': rec.brand_id.id if rec.brand_id else False,
+ 'standard_price': rec.price or 0,
+ 'public_categ_ids': [(6, 0, rec.product_class.ids)] if rec.product_class else False,
+ 'active': True,
+ 'sourcing_job_id': job.id if job else False,
+ })
+
+ if not rec.code:
+ padded_id = str(product.id).zfill(7)
+ sku_auto = f"IT.{padded_id}"
+ product.default_code = sku_auto
+ rec.code = sku_auto
+
+ rec.product_id = product.id
+
+ self.env.user.notify_success(
+ message=f"Produk baru '{product.name}' berhasil dibuat dengan SKU {product.default_code}.",
+ title="Product Created"
+ )
+
+ jakarta_tz = rec.order_id._get_jakarta_today()
+
+ purchase_price = PurchasePricelist.search([
+ ('product_id', '=', product.id),
+ ('vendor_id', '=', rec.vendor_id.id),
+ ], order="human_last_update desc", limit=1)
+
+ pricelist_vals = {
+ 'product_id': product.id,
+ 'vendor_id': rec.vendor_id.id,
+ 'product_price': rec.price or 0,
+ 'include_price': rec.price or 0,
+ 'taxes_product_id': rec.tax_id.id if rec.tax_id else False,
+ 'brand_id': product.x_manufacture.id if product.x_manufacture else False,
+ 'human_last_update': jakarta_tz,
+ 'is_winner': True,
+ }
+
+ if not purchase_price:
+ PurchasePricelist.create(pricelist_vals)
+
+ elif purchase_price.product_price != (rec.price or 0):
+ purchase_price.write(pricelist_vals)
+
+ if rec.so_id and not rec.exported_to_so:
+ so = rec.so_id
+
+ so_line_new = SaleOrderLine.new({
+ "order_id": so.id,
+ "product_id": product.id,
+ "product_uom_qty": rec.quantity or 1,
+ "price_unit": rec.price or 0,
+ "name": rec.product_name_md,
+ })
+
+ so_line_new.product_id_change()
+ vals = SaleOrderLine._convert_to_write(so_line_new._cache)
+ SaleOrderLine.create(vals)
+
+ rec.exported_to_so = True
+
+ activities = self.env['mail.activity'].search([
+ ('res_model', '=', rec._name),
+ ('res_id', '=', rec.id),
+ ])
+ activities.unlink()
+
+ rec.message_post(
+ body=(
+ f"โœ… <b>Approval disetujui oleh {self.env.user.name}</b><br/>"
+ f"Produk siap untuk proses selanjutnya (convert / PO / SO)."
+ ),
+ subtype_xmlid="mail.mt_comment"
+ )
+
+ job.message_post(
+ body=(
+ f"โœ… <b>Approval produk disetujui</b><br/>"
+ f"<ul>"
+ f"<li><b>Produk:</b> {rec.product_name_md}</li>"
+ f"<li><b>Vendor:</b> {rec.vendor_id.display_name if rec.vendor_id else '-'}</li>"
+ f"<li><b>Harga:</b> {rec.price or 0}</li>"
+ f"<li><b>Disetujui oleh:</b> {self.env.user.name}</li>"
+ f"</ul>"
+ ),
+ subtype_xmlid="mail.mt_comment"
+ )
+
+ if rec.md_person_ids:
+ rec.md_person_ids.notify_success(
+ message=f"Produk '{rec.product_name_md}' telah di-approve sales.",
+ title="Approval Approved"
+ )
+
+ self.env.user.notify_success(
+ message=f"Produk '{rec.product_name_md}' berhasil di-approve.",
+ title="Approved"
+ )
+
+ so = self.mapped('so_id')[:1]
+ if so:
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Sales Order',
+ 'res_model': 'sale.order',
+ 'view_mode': 'form',
+ 'res_id': so.id,
+ 'target': 'current',
+ }
+
+ return {'type': 'ir.actions.client', 'tag': 'reload'}
+
+ def action_multi_approve(self):
+ so_ids = self.mapped('so_id').ids
+
+ if len(set(so_ids)) > 1:
+ raise UserError("โŒ Multi approve hanya bisa dilakukan jika semua line berasal dari Sales Order yang sama.")
+ self.action_approve_approval()
+
+ def action_reject_approval(self):
+ self.ensure_one()
+
+ job = self.order_id
+ if job.create_uid != self.env.user:
+ raise UserError("โŒ Hanya pembuat Sourcing Job yang bisa reject approval.")
+
+ return {
+ 'name': 'Reason Reject',
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'sourcing.reject.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'default_line_id': self.id
+ }
+ }
+
+ def action_cancel(self):
+ for rec in self:
+ if self.env.user != rec.md_person_ids:
+ raise UserError("Hanya MD Person Job ini yang bisa Cancel.")
+
+ if not rec.reason:
+ raise UserError("Isi Reason untuk Cancel Job.")
+
+ line_no = 1
+ if rec.order_id:
+ all_lines = self.search(
+ [('order_id', '=', rec.order_id.id)],
+ order='id asc'
+ )
+ for i, r in enumerate(all_lines, start=1):
+ if r.id == rec.id:
+ line_no = i
+ break
+
+ rec.write({'state': 'cancel'})
+
+ rec.message_post(
+ body=(
+ "Line <b>%s</b> dari Order <b>%s</b> di Cancel oleh <b>%s</b><br/>"
+ "Reason: %s"
+ ) % (
+ line_no,
+ rec.order_id.name or '-',
+ self.env.user.name,
+ rec.reason or '-'
+ )
+ )
+
+ @api.onchange('product_id')
+ def _oncange_code(self):
+ for rec in self:
+ if not rec.product_id:
+ continue
+
+ product = rec.product_id
+ if not product:
+ return
+ template = product.product_tmpl_id
+ attribute_values = product.product_template_attribute_value_ids.mapped(
+ 'product_attribute_value_id.name'
+ )
+ attribute_values_str = ', '.join(attribute_values) if attribute_values else ''
+
+ # generate line name
+ line_name = (
+ ('[' + product.default_code + '] ' if product.default_code else '') +
+ (product.name or '') +
+ (' (' + attribute_values_str + ')' if attribute_values_str else '') +
+ (' ' + product.short_spesification if product.short_spesification else '')
+ )
+
+ rec.code = product.default_code or rec.code
+ rec.product_name_md = product.name or rec.product_name_md
+ rec.descriptions_md = line_name.strip() or rec.descriptions_md
+ rec.product_type = template.type or rec.product_type
+ rec.brand_id = product.x_manufacture.id or rec.brand_id
+ rec.product_category = template.categ_id.id or rec.product_category
+ rec.product_class = [(6, 0, template.public_categ_ids.ids)] if template.public_categ_ids else []
+
+ pricelist = self.env['purchase.pricelist'].search([('product_id', '=', product.id), ('is_winner', '=', True)], limit=1)
+ if pricelist:
+ rec.vendor_id = pricelist.vendor_id.id or False
+ rec.price = pricelist.include_price or 0.0
+ rec.now_price = pricelist.include_price or 0.0
+ rec.last_updated_price = pricelist.write_date or 0.0
+ rec.tax_id = pricelist.taxes_product_id.id or pricelist.taxes_system_id.id or False
+
+ @api.onchange('attachment_type')
+ def _onchange_attachment_type(self):
+ for rec in self:
+ if rec.attachment_type == 'pdf':
+ rec.product_attachment_img = False
+ rec.product_attachment_other = False
+
+ elif rec.attachment_type == 'img':
+ rec.product_attachment_pdf = False
+ rec.product_attachment_other = False
+
+ elif rec.attachment_type == 'other':
+ rec.product_attachment_pdf = False
+ rec.product_attachment_img = False
+
+ else:
+ rec.product_attachment_pdf = False
+ rec.product_attachment_img = False
+ rec.product_attachment_other = False
+
+ @api.onchange(
+ 'product_attachment_pdf',
+ 'product_attachment_img',
+ 'product_attachment_other',
+ 'attachment_type'
+ )
+ def _onchange_set_filename(self):
+ for rec in self:
+ sjo_number = rec.order_id.name if rec.order_id and rec.order_id.name else 'SJO'
+
+ if rec.attachment_type == 'pdf' and rec.product_attachment_pdf:
+ rec.product_attachment_filename = f"{sjo_number}.pdf"
+
+ elif rec.attachment_type == 'img' and rec.product_attachment_img:
+ rec.product_attachment_filename = f"{sjo_number}.png"
+
+ elif rec.attachment_type == 'other' and rec.product_attachment_other:
+ rec.product_attachment_filename = f"{sjo_number}_file"
+
+ def action_reopen_cancel(self):
+ self.ensure_one()
+
+ if self.state != 'cancel':
+ raise UserError("Cuma line cancel yang bisa direopen.")
+ if self.order_id.create_uid != self.env.user:
+ raise UserError("Cuma Pemilik SJO yang bisa Re-Open.")
+
+ return {
+ 'name': 'Reason Reopen',
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'reopen.cancel.line.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'default_line_id': self.id
+ }
+ }
+
+ def action_open_give_wizard(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Give To MD',
+ 'res_model': 'sjo.give.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'default_line_id': self.id,
+ }
+ }
+
+ def action_open_reject_given_wizard(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Reject Request Give SJO Line',
+ 'res_model': 'sjo.reject.give.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {
+ 'default_line_id': self.id,
+ }
+ }
+
+ def action_take_given(self):
+ for rec in self:
+ if self.env.user != rec.given_to_id:
+ raise UserError("Hanya MD yang diberikan Request yang bisa Take Sourcing")
+
+ old_owner = rec.previous_md_id.name
+ new_owner = rec.given_to_id.name
+ receiver = rec.given_to_id
+
+ rec.with_context(bypass_md_check=True).write({
+ 'md_person_ids': rec.given_to_id.id,
+ 'given_to_id': False,
+ 'previous_md_id': False,
+ 'is_given': False,
+ })
+
+ rec._unlink_give_activity(receiver)
+ rec.message_post(
+ body=f"<b>{new_owner}<b/> Menerima Request Sourcing dari <b>{old_owner}<b/>"
+ )
+
+class SjoGiveWizard(models.TransientModel):
+ _name = 'sjo.give.wizard'
+ _description = 'Give SJO Line Wizard'
+
+ line_id = fields.Many2one('sourcing.job.order.line')
+ md_id = fields.Many2one('res.users', string='Give To', required=True, domain=lambda self: [
+ ('groups_id', 'in', self.env.ref('base.group_user').ids),
+ ('groups_id', 'in', self.env.ref('indoteknik_custom.group_role_merchandiser').ids),
+ ('active', '=', True)
+ ])
+
+ def action_confirm(self):
+ self.ensure_one()
+
+ line = self.line_id
+
+ if self.env.user != line.md_person_ids:
+ raise UserError("Hanya Md Target yang bisa Confirm Give Sourcing")
+
+ old_owner = line.md_person_ids.name
+ new_owner = self.md_id.name
+
+ line.write({
+ 'previous_md_id': line.md_person_ids.id,
+ 'given_to_id': self.md_id.id,
+ 'is_given': True,
+ })
+
+ activity_type = self.env.ref('mail.mail_activity_data_todo')
+ line.activity_schedule(
+ activity_type_id=activity_type.id,
+ user_id=self.md_id.id,
+ note="SJO Line diberikan ke Anda. Silakan Take atau Reject.",
+ )
+
+ line.message_post(
+ body=f"""
+ <b>MD {old_owner}</b> Mengirim Request Peralihan Sourcing Ke <b>{new_owner}</b>
+ """,
+ subtype_xmlid="mail.mt_comment"
+ )
+
+class SjoRejectGiveWizard(models.TransientModel):
+ _name = 'sjo.reject.give.wizard'
+ _description = 'Reject Given SJO Line Wizard'
+
+ line_id = fields.Many2one('sourcing.job.order.line', required=True)
+ reason = fields.Text(string="Reject Reason", required=True)
+
+ def action_confirm(self):
+ self.ensure_one()
+ line = self.line_id
+
+ if self.env.user != line.given_to_id:
+ raise UserError("Hanya Penerima Request yang bisa Reject Give")
+
+ from_md = line.previous_md_id.name or "-"
+ receiver = line.given_to_id
+ rejector = self.env.user.name
+
+ line._unlink_give_activity(receiver)
+
+ line.with_context(bypass_md_check=True).write({
+ 'given_to_id': False,
+ 'is_given': False,
+ })
+
+ line.message_post(
+ body=f"""
+ Request Peralihan dari <b>{from_md}</b> Rejected by <b>{rejector}</b><br/>
+ Alasan: {self.reason}
+ """,
+ subtype_xmlid="mail.mt_comment"
+ )
+
+class WizardExportSJOtoSO(models.TransientModel):
+ _name = "wizard.export.sjo.to.so"
+ _description = "Wizard Export SJO Products to SO"
+
+ sjo_id = fields.Many2one("sourcing.job.order", string="Sourcing Job ID")
+ line_ids = fields.Many2many("sourcing.job.order.line", string="SJO Lines")
+ product_ids = fields.Many2many(
+ "product.product",
+ string="Products",
+ compute="_compute_products",
+ store=False,
+ )
+
+ @api.model
+ def default_get(self, fields):
+ res = super().default_get(fields)
+ sjo_id = self.env.context.get("default_sjo_id")
+
+ if sjo_id:
+ # ambil line yg punya product & belum di export
+ lines = self.env["sourcing.job.order.line"].search([
+ ("order_id", "=", sjo_id),
+ ("product_id", "!=", False),
+ ("exported_to_so", "=", False),
+ ("state", "=", "approve"), # optional: cuma yg done
+ ])
+ res["line_ids"] = [(6, 0, lines.ids)]
+
+ return res
+
+ @api.depends("line_ids")
+ def _compute_products(self):
+ for rec in self:
+ rec.product_ids = rec.line_ids.mapped("product_id")
+
+ def action_confirm(self):
+ self.ensure_one()
+ sjo = self.sjo_id
+
+ if not sjo.so_id:
+ raise UserError("Sales Order belum dipilih di SJO!")
+
+ so = sjo.so_id
+ SaleOrderLine = self.env["sale.order.line"]
+
+ for line in self.line_ids:
+ if not line.product_id:
+ continue
+
+ # bikin SOL dari product
+ so_line_new = SaleOrderLine.new({
+ "order_id": so.id,
+ "product_id": line.product_id.id,
+ "product_uom_qty": line.quantity or 1,
+ "price_unit": line.price or 0,
+ "name": line.product_name,
+ })
+
+ so_line_new.product_id_change()
+ vals = SaleOrderLine._convert_to_write(so_line_new._cache)
+ new_line = SaleOrderLine.create(vals)
+
+ # tandai sudah export
+ line.exported_to_so = True
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'sale.order',
+ 'view_mode': 'form',
+ 'res_id': so.id,
+ 'target': 'current',
+ }
+
+class SourcingJobOrderLineImportWizard(models.TransientModel):
+ _name = 'sourcing.job.order.line.import.wizard'
+ _description = 'Import SJO Line from Excel'
+
+ excel_file = fields.Binary("Excel File", required=True)
+ filename = fields.Char("Filename")
+ order_id = fields.Many2one('sourcing.job.order', string="Sourcing Job Order", required=True)
+
+ def action_import_excel(self):
+ if not self.excel_file:
+ raise UserError(_("โš ๏ธ Harap upload file Excel terlebih dahulu."))
+
+ try:
+ data = base64.b64decode(self.excel_file)
+ book = xlrd.open_workbook(file_contents=data)
+ sheet = book.sheet_by_index(0)
+ except:
+ raise UserError(_("โŒ Format Excel tidak valid atau rusak."))
+
+ header = [str(sheet.cell(0, col).value).strip() for col in range(sheet.ncols)]
+ required_headers = [
+ 'Nama Barang', 'SKU', 'Expected Price', 'Note Sourcing', 'Brand',
+ 'Deskripsi / Spesifikasi', 'SLA Product', 'Quantity Product',
+ 'Purchase Price', 'Tax', 'Vendor', 'Product Category',
+ 'Categories', 'Product Type'
+ ]
+
+ for req in required_headers:
+ if req not in header:
+ raise UserError(_("โŒ Kolom '%s' tidak ditemukan di file Excel.") % req)
+
+ header_map = {h: idx for idx, h in enumerate(header)}
+ lines_created = 0
+ ProductLine = self.env['sourcing.job.order.line']
+ Tax = self.env['account.tax']
+ Vendor = self.env['res.partner']
+ Category = self.env['product.category']
+ PublicCategory = self.env['product.public.category']
+
+ for row_idx in range(1, sheet.nrows):
+ row = sheet.row(row_idx)
+ def val(field):
+ return str(sheet.cell(row_idx, header_map[field]).value).strip()
+
+ if not val('Nama Barang'):
+ continue # skip kosong
+
+ # Relations
+ tax = Tax.search([('name', 'ilike', val('Tax'))], limit=1)
+ vendor = Vendor.search([('name', 'ilike', val('Vendor'))], limit=1)
+ category = Category.search([('name', 'ilike', val('Product Category'))], limit=1)
+
+ # Many2many: Categories
+ class_names = val('Categories').split(';')
+ class_ids = []
+ for name in class_names:
+ name = name.strip()
+ if name:
+ pc = PublicCategory.search([('name', 'ilike', name)], limit=1)
+ if pc:
+ class_ids.append(pc.id)
+
+ # Build values
+ vals = {
+ 'order_id': self.order_id.id,
+ 'product_name': val('Nama Barang'),
+ 'code': val('SKU'),
+ 'budget': val('Expected Price'),
+ 'note': val('Note Sourcing'),
+ 'brand': val('Brand'),
+ 'descriptions': val('Deskripsi / Spesifikasi'),
+ 'sla': val('SLA Product'),
+ 'quantity': float(val('Quantity Product') or 0),
+ 'price': float(val('Purchase Price') or 0),
+ 'tax_id': tax.id if tax else False,
+ 'vendor_id': vendor.id if vendor else False,
+ 'product_category': category.id if category else False,
+ 'product_type': val('Product Type') or 'product',
+ 'product_class': [(6, 0, class_ids)],
+ }
+
+ ProductLine.create(vals)
+ lines_created += 1
+
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': _('โœ… Import Selesai'),
+ 'message': _('%s baris berhasil diimport.') % lines_created,
+ 'type': 'success',
+ 'sticky': False,
+ }
+ }
+
+class SourcingJobOrderLineExportWizard(models.TransientModel):
+ _name = 'sourcing.job.order.line.export.wizard'
+ _description = 'Export SJO Line Wizard'
+
+ order_id = fields.Many2one('sourcing.job.order', string="Sourcing Job Order", required=True)
+ file = fields.Binary("CSV File", readonly=True)
+ filename = fields.Char("Filename", readonly=True)
+
+ def action_export(self):
+ if not self.order_id:
+ raise UserError("Silakan pilih Sourcing Job Order terlebih dahulu.")
+
+ lines = self.env['sourcing.job.order.line'].search([('order_id', '=', self.order_id.id)])
+ wb = xlwt.Workbook()
+ sheet = wb.add_sheet("SJO Lines")
+
+ headers = [
+ 'Nama Barang', 'SKU', 'Expected Price', 'Note Sourcing', 'Brand',
+ 'Deskripsi / Spesifikasi', 'SLA Product', 'Quantity Product',
+ 'Purchase Price', 'Tax', 'Vendor', 'Product Category',
+ 'Categories', 'Product Type'
+ ]
+
+ # Write header
+ for col, header in enumerate(headers):
+ sheet.write(0, col, header)
+
+ for row_idx, line in enumerate(lines, start=1):
+ categories = '; '.join(line.product_class.mapped('name')) or ''
+ values = [
+ line.product_name or '',
+ line.code or '',
+ line.budget or '',
+ line.note or '',
+ line.brand or '',
+ line.descriptions or '',
+ line.sla or '',
+ line.quantity or 0,
+ line.price or 0,
+ line.tax_id.name if line.tax_id else '',
+ line.vendor_id.name if line.vendor_id else '',
+ line.product_category.name if line.product_category else '',
+ categories,
+ line.product_type or '',
+ ]
+ for col_idx, value in enumerate(values):
+ sheet.write(row_idx, col_idx, value)
+
+ # Save to binary
+ fp = io.BytesIO()
+ wb.save(fp)
+ fp.seek(0)
+ data = fp.read()
+ fp.close()
+
+ self.file = base64.b64encode(data)
+ self.filename = f"SJO_{self.order_id.name}_lines.xls" # Note: xlwt hanya mendukung .xls
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': self._name,
+ 'view_mode': 'form',
+ 'res_id': self.id,
+ 'target': 'new',
+ }
+
+
+class SourcingJobOrderLineTemplateWizard(models.TransientModel):
+ _name = 'sourcing.job.order.line.template.wizard'
+ _description = 'Download & Import Template SJO Line'
+
+ file = fields.Binary("Template", readonly=True)
+ filename = fields.Char("Filename", readonly=True)
+
+ order_id = fields.Many2one(
+ 'sourcing.job.order',
+ string="Sourcing Job Order",
+ required=True,
+ domain="[('state', '=', 'taken')]",
+ default=lambda self: self.env.context.get('active_id')
+ )
+
+ excel_file = fields.Binary("Upload Excel")
+ excel_filename = fields.Char("Excel Filename")
+
+ def action_generate_template(self):
+ output = io.BytesIO()
+ wb = xlwt.Workbook()
+ ws = wb.add_sheet('Template')
+
+ headers = [
+ 'Nama Barang', 'SKU', 'Expected Price', 'Note Sourcing', 'Brand',
+ 'Deskripsi / Spesifikasi', 'SLA Product', 'Quantity Product',
+ 'Purchase Price', 'Tax', 'Vendor', 'Product Category',
+ 'Categories', 'Product Type'
+ ]
+
+ for col, header in enumerate(headers):
+ ws.write(0, col, header)
+
+ wb.save(output)
+ output.seek(0)
+
+ self.file = base64.b64encode(output.read())
+ self.filename = "SJO_import_template.xls"
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': self._name,
+ 'view_mode': 'form',
+ 'res_id': self.id,
+ 'target': 'new',
+ }
+
+ def action_import_excel(self):
+ if not self.excel_file:
+ raise UserError(_("โš ๏ธ Harap upload file Excel terlebih dahulu."))
+
+ if not self.order_id:
+ raise UserError(_("โš ๏ธ Pilih Sourcing Job Order dulu."))
+
+ try:
+ data = base64.b64decode(self.excel_file)
+ book = xlrd.open_workbook(file_contents=data)
+ sheet = book.sheet_by_index(0)
+ except:
+ raise UserError(_("โŒ Format Excel tidak valid atau rusak."))
+
+ header = [str(sheet.cell(0, col).value).strip() for col in range(sheet.ncols)]
+ required_headers = [
+ 'Nama Barang', 'SKU', 'Expected Price', 'Note Sourcing', 'Brand',
+ 'Deskripsi / Spesifikasi', 'SLA Product', 'Quantity Product',
+ 'Purchase Price', 'Tax', 'Vendor', 'Product Category',
+ 'Categories', 'Product Type'
+ ]
+
+ for req in required_headers:
+ if req not in header:
+ raise UserError(_("โŒ Kolom '%s' tidak ditemukan di file Excel.") % req)
+
+ header_map = {h: idx for idx, h in enumerate(header)}
+ lines_created = 0
+
+ ProductLine = self.env['sourcing.job.order.line']
+ Tax = self.env['account.tax']
+ Vendor = self.env['res.partner']
+ Category = self.env['product.category']
+ PublicCategory = self.env['product.public.category']
+
+ for row_idx in range(1, sheet.nrows):
+
+ def val(field):
+ return str(sheet.cell(row_idx, header_map[field]).value).strip()
+
+ if not val('Nama Barang'):
+ continue
+
+ tax = Tax.search([('name', 'ilike', val('Tax'))], limit=1)
+ vendor = Vendor.search([('name', 'ilike', val('Vendor'))], limit=1)
+ category = Category.search([('name', 'ilike', val('Product Category'))], limit=1)
+
+ # many2many categories
+ class_names = val('Categories').split(';')
+ class_ids = []
+ for name in class_names:
+ name = name.strip()
+ if name:
+ pc = PublicCategory.search([('name', 'ilike', name)], limit=1)
+ if pc:
+ class_ids.append(pc.id)
+
+ vals = {
+ 'order_id': self.order_id.id,
+ 'product_name': val('Nama Barang'),
+ 'code': val('SKU'),
+ 'budget': float(val('Expected Price') or 0),
+ 'note': val('Note Sourcing'),
+ 'brand': val('Brand'),
+ 'descriptions': val('Deskripsi / Spesifikasi'),
+ 'sla': val('SLA Product'),
+ 'quantity': float(val('Quantity Product') or 0),
+ 'price': float(val('Purchase Price') or 0),
+ 'tax_id': tax.id if tax else False,
+ 'vendor_id': vendor.id if vendor else False,
+ 'product_category': category.id if category else False,
+ 'product_type': val('Product Type') or 'product',
+ 'product_class': [(6, 0, class_ids)],
+ 'state': 'sourcing',
+ 'md_person_id': self.env.user,
+ }
+
+ ProductLine.create(vals)
+ lines_created += 1
+
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': _('โœ… Import Selesai'),
+ 'message': _('%s baris berhasil diimport.') % lines_created,
+ 'type': 'success',
+ 'sticky': False,
+ }
+ }
+
+class SourcingRejectWizard(models.TransientModel):
+ _name = 'sourcing.reject.wizard'
+ _description = 'Wizard alasan reject produk sourcing oleh sales'
+
+ line_id = fields.Many2one('sourcing.job.order.line', string='Sourcing Line', required=True)
+ reason = fields.Text(string='Alasan Penolakan', required=True)
+
+ def action_confirm_reject(self):
+ self.ensure_one()
+ line = self.line_id
+ job = line.order_id
+
+ line.state = 'sourcing'
+
+ activities = self.env['mail.activity'].search([
+ ('res_model', '=', line._name),
+ ('res_id', '=', line.id),
+ ])
+
+ activities.unlink()
+ line.message_post(
+ body=(
+ f"โŒ <b>Approval ditolak oleh {self.env.user.name}</b><br/>"
+ f"<b>Produk:</b> {line.product_name}<br/>"
+ f"<b>Alasan:</b><br/>{self.reason}"
+ ),
+ subtype_xmlid="mail.mt_comment"
+ )
+
+ job.message_post(
+ body=(
+ f"โŒ <b>Approval produk ditolak</b><br/>"
+ f"<ul>"
+ f"<li><b>Produk:</b> {line.product_name}</li>"
+ f"<li><b>Ditolak oleh:</b> {self.env.user.name}</li>"
+ f"<li><b>Alasan:</b> {self.reason}</li>"
+ f"</ul>"
+ ),
+ subtype_xmlid="mail.mt_comment"
+ )
+
+ if line.md_person_ids:
+ line.md_person_ids.notify_warning(
+ message=f"Produk '{line.product_name}' direject sales. Silakan sourcing ulang.",
+ title="Approval Ditolak"
+ )
+
+ self.env.user.notify_info(
+ message=f"Produk '{line.product_name}' berhasil direject.",
+ title="Rejected"
+ )
+
+ return {'type': 'ir.actions.client', 'tag': 'reload'}
+
+class ReopenCancelLineWizard(models.TransientModel):
+ _name = 'reopen.cancel.line.wizard'
+ _description = 'Reopen Cancel Line Reason'
+
+ line_id = fields.Many2one('sourcing.job.order.line', required=True)
+ reason = fields.Text(required=True, string="Reason Reopen")
+
+ def action_confirm(self):
+ self.ensure_one()
+ line = self.line_id
+
+ if line.order_id.create_uid != self.env.user:
+ raise UserError("Line ini bukan bagian dari SJO anda.")
+
+ # post message dulu
+ line.message_post(
+ body=(
+ "Line <b>%s</b> di REOPEN oleh <b>%s</b><br/>"
+ "<b>Reason:</b> %s"
+ ) % (
+ line.product_id.display_name or '-',
+ self.env.user.name,
+ self.reason
+ )
+ )
+
+ # reset field
+ line.write({
+ 'state': 'draft',
+ 'md_person_ids': False,
+ }) \ No newline at end of file
diff --git a/indoteknik_custom/security/ir.model.access.csv b/indoteknik_custom/security/ir.model.access.csv
index e869fff1..b685413d 100755
--- a/indoteknik_custom/security/ir.model.access.csv
+++ b/indoteknik_custom/security/ir.model.access.csv
@@ -221,8 +221,18 @@ access_gudang_service_line,gudang.service.line,model_gudang_service_line,base.gr
access_update_depreciation_move_wizard,access.update.depreciation.move.wizard,model_update_depreciation_move_wizard,,1,1,1,1
access_keywords,keywords,model_keywords,base.group_user,1,1,1,1
+access_sourcing_job_order,access.sourcing_job_order,model_sourcing_job_order,base.group_user,1,1,1,1
+access_sourcing_job_order_line_user,sourcing.job.order.line,model_sourcing_job_order_line,base.group_user,1,1,1,1
+access_sourcing_reject_wizard,sourcing.reject.wizard,model_sourcing_reject_wizard,base.group_user,1,1,1,1
+access_wizard_export_sjo_to_so,wizard.export.sjo.to.so,model_wizard_export_sjo_to_so,base.group_user,1,1,1,1
+access_sourcing_job_order_line_import_wizard,sourcing.job.order.line.import.wizard,model_sourcing_job_order_line_import_wizard,base.group_user,1,1,1,1
+access_sourcing_job_order_line_export_wizard,sourcing.job.order.line.export.wizard,model_sourcing_job_order_line_export_wizard,base.group_user,1,1,1,1
+access_sourcing_job_order_line_template_wizard,sourcing.job.order.line.template.wizard,model_sourcing_job_order_line_template_wizard,base.group_user,1,1,1,1
+access_sjo_give_wizard_user,sjo.give.wizard user,model_sjo_give_wizard,base.group_user,1,1,1,1
+access_sjo_reject_give_wizard_user,sjo.reject.give.wizard user,model_sjo_reject_give_wizard,base.group_user,1,1,1,1
access_token_log,access.token.log,model_token_log,,1,1,1,1
access_purchase_order_forecast_line,access.purchase.order.forecast.line,model_purchase_order_forecast_line,,1,1,1,1
access_sale_forecast_coverage,access.sale.forecast.coverage,model_sale_forecast_coverage,,1,1,1,1
+access_reopen_cancel_line_wizard,reopen.cancel.line.wizard,model_reopen_cancel_line_wizard,base.group_user,1,1,1,1
access_account_move_change_date_wizard,access.account.move.change.date.wizard,model_account_move_change_date_wizard,,1,1,1,1
diff --git a/indoteknik_custom/views/ir_sequence.xml b/indoteknik_custom/views/ir_sequence.xml
index 55e48300..818c5d30 100644
--- a/indoteknik_custom/views/ir_sequence.xml
+++ b/indoteknik_custom/views/ir_sequence.xml
@@ -241,6 +241,15 @@
<field name="active">True</field>
</record>
+ <record id="seq_sourcing_job_order" model="ir.sequence">
+ <field name="name">Sourcing Job Order</field>
+ <field name="code">sourcing.job.order</field>
+ <field name="prefix">SJO/%(year)s/</field>
+ <field name="padding">4</field>
+ <field name="number_next_actual">1</field>
+ <field name="number_increment">1</field>
+ </record>
+
<record id="seq_refund_sale_order" model="ir.sequence">
<field name="name">Refund Sales Order</field>
<field name="code">refund.sale.order</field>
diff --git a/indoteknik_custom/views/sale_order.xml b/indoteknik_custom/views/sale_order.xml
index 69dafe62..79604e75 100755
--- a/indoteknik_custom/views/sale_order.xml
+++ b/indoteknik_custom/views/sale_order.xml
@@ -46,6 +46,12 @@
type="object"
string="Refund"
class="btn-primary" />
+
+ <button name="action_open_sjo"
+ type="object"
+ string="Request Sourcing"
+ class="btn-primary"
+ attrs="{'invisible': [('state', '!=', 'draft')]}"/>
</xpath>
<xpath expr="//sheet" position="before">
<field name="partner_is_cbd_locked" invisible="1"/>
@@ -77,6 +83,14 @@
attrs="{'invisible': [('refund_count', '=', 0)]}">
<field name="refund_count" widget="statinfo" string="Refund"/>
</button>
+
+ <button type="object"
+ name="action_view_related_sjo"
+ class="oe_stat_button"
+ icon="fa-briefcase"
+ attrs="{'invisible': [('sourcing_job_count', '=', 0)]}">
+ <field name="sourcing_job_count" widget="statinfo" string="SJO"/>
+ </button>
</div>
<field name="payment_term_id" position="after">
<field name="create_uid" invisible="1"/>
diff --git a/indoteknik_custom/views/sourcing.xml b/indoteknik_custom/views/sourcing.xml
new file mode 100644
index 00000000..cd1de5a4
--- /dev/null
+++ b/indoteknik_custom/views/sourcing.xml
@@ -0,0 +1,802 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<odoo>
+ <!-- Sales View -->
+ <record id="view_sourcing_job_order_search" model="ir.ui.view">
+ <field name="name">sourcing.job.order.search</field>
+ <field name="model">sourcing.job.order</field>
+ <field name="arch" type="xml">
+ <search string="Search Sourcing Job Order">
+ <field name="state" string="Status"/>
+ <filter name="done"
+ string="Complete"
+ domain="[('state', '=', 'done')]" />
+ <filter name="by_create_uid"
+ string="My Activity"
+ domain="[('create_uid', '=', uid)]"/>
+ </search>
+ </field>
+ </record>
+
+ <record id="view_wizard_export_sjo_to_so_form" model="ir.ui.view">
+ <field name="name">wizard.export.sjo.to.so.form</field>
+ <field name="model">wizard.export.sjo.to.so</field>
+ <field name="arch" type="xml">
+ <form string="Export Products to SO">
+ <group>
+ <field name="sjo_id" readonly="1"/>
+ <field name="line_ids" widget="many2many_tags" invisible="1"/>
+ <field name="product_ids" widget="many2many_tags" options="{'no_create': True}"/>
+ </group>
+
+ <footer>
+ <button name="action_confirm"
+ string="Export to SO"
+ type="object"
+ class="btn-primary"/>
+
+ <button string="Cancel"
+ class="btn-secondary"
+ special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="view_reopen_cancel_line_wizard" model="ir.ui.view">
+ <field name="name">reopen.cancel.line.wizard.form</field>
+ <field name="model">reopen.cancel.line.wizard</field>
+ <field name="arch" type="xml">
+ <form string="Reason Reopen">
+ <group>
+ <field name="reason" placeholder="Alasan kenapa mau direopen..."/>
+ </group>
+ <footer>
+ <button name="action_confirm"
+ type="object"
+ string="Confirm Reopen"
+ class="btn-primary"/>
+ <button string="Cancel" special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="view_sourcing_job_order_tree" model="ir.ui.view">
+ <field name="name">sourcing.job.order.tree</field>
+ <field name="model">sourcing.job.order</field>
+ <field name="arch" type="xml">
+ <tree string="Sourcing Job Orders" create="0"
+ decoration-success="state=='done'"
+ decoration-info="state=='partial'"
+ decoration-danger="state=='cancel'"
+ decoration-warning="state=='taken'">
+ <field name="is_priority" optional="hide" readonly="1"/>
+ <field name="name"/>
+ <field name="eta_sales" optional="hide"/>
+ <field name="eta_complete" optional="hide"/>
+ <!-- <field name="leads_id"/> -->
+ <field name="create_uid" widget="many2one_avatar_user"/>
+ <field name="md_user_id" widget="many2many_tags"/>
+ <field name="state" widget="badge"/>
+ <field name="create_date"/>
+ <field name="progress_status"
+ decoration-info="progress_status in ['๐ŸŸก On Track', '๐Ÿ”ต Ontime']"
+ decoration-success="'๐ŸŸข' in progress_status"
+ decoration-danger="'๐Ÿ”ด' in progress_status"
+ decoration-muted="'โšซ' in progress_status"/>
+ </tree>
+ </field>
+ </record>
+
+ <record id="view_sourcing_job_order_form" model="ir.ui.view">
+ <field name="name">sourcing.job.order.form</field>
+ <field name="model">sourcing.job.order</field>
+ <field name="arch" type="xml">
+ <form string="Sourcing Job Order" create="0">
+ <header>
+ <button name="action_cancel"
+ string="Cancel"
+ type="object"
+ class="btn-secondary"
+ icon="fa-times"
+ groups="indoteknik_custom.group_role_merchandiser"
+ attrs="{'invisible': [('state', 'in', ['cancel', 'done'])]}"/>
+
+ <field name="state" widget="statusbar"
+ statusbar_visible="draft,taken,partial,done,cancel" attrs="{'invisible': [('state', '!=', 'cancel')]}"/>
+
+ <field name="state" widget="statusbar"
+ statusbar_visible="draft,taken,partial,done" attrs="{'invisible': [('state', '=', 'cancel')]}"/>
+ </header>
+
+ <sheet>
+ <div class="oe_button_box" name="button_box">
+ <button name="action_open_converted_products"
+ type="object"
+ class="oe_stat_button"
+ icon="fa-cubes"
+ attrs="{'invisible': [('converted_product_count', '=', 0)]}">
+ <field name="converted_product_count" widget="statinfo" string="Products"/>
+ </button>
+ </div>
+ <widget name="web_ribbon"
+ title="COMPLETE"
+ bg_color="bg-success"
+ attrs="{'invisible': [('state', '!=', 'done')]}"/>
+
+ <widget name="web_ribbon"
+ title="CANCEL"
+ bg_color="bg-danger"
+ attrs="{'invisible': [('state', '!=', 'cancel')]}"/>
+ <h1>
+ <field name="name" readonly="1"/>
+ </h1>
+
+ <group>
+ <group>
+ <!-- <field name="leads_id" readonly="1"/> -->
+ <field name="eta_sales"/>
+ <field name="eta_complete" readonly="1"/>
+ <field name="has_price_in_lines" invisible="1"/>
+ <field name="so_id"/>
+ </group>
+ <group>
+ <field name="create_uid" readonly="1" widget="many2one_avatar_user"/>
+ <field name="md_user_id" widget="many2many_tags"/>
+ <field name="approval_sales" readonly="1"/>
+ <field name="progress_status"
+ decoration-info="progress_status in ['๐ŸŸก On Track', '๐Ÿ”ต Ontime']"
+ decoration-success="'๐ŸŸข' in progress_status"
+ decoration-danger="'๐Ÿ”ด' in progress_status"
+ decoration-muted="'โšซ' in progress_status"/>
+ </group>
+ </group>
+
+ <notebook>
+ <page string="Product Line" groups="indoteknik_custom.group_role_sales"
+ attrs="{'invisible': [('has_price_in_lines', '=', True)]}">
+ <field name="line_sales_input_ids" attrs="{'readonly': [('state', '!=', 'draft')]}">
+ <tree>
+ <field name="brand"/>
+ <field name="product_name"/>
+ <field name="descriptions"/>
+ <field name="quantity"/>
+ <field name="md_person_ids" widget="many2one_avatar_user"/>
+ <field name="state" readonly="1" widget="badge"
+ decoration-warning="state == 'sourcing'"
+ decoration-info="state == 'sent'"
+ decoration-success="state == 'approve'"
+ decoration-danger="state == 'cancel'"/>
+ <field name="note" optional="hide"/>
+ <field name="budget" optional="hide"/>
+ </tree>
+ <form string="Product Line">
+ <group>
+ <field name="brand" required="1"/>
+ <small style="margin-top:-1.4rem; display:block;">*Masukkan merek produk. Jika tidak diketahui, dapat diisi dengan "No Brand".</small>
+
+ <field name="product_name" required="1"/>
+ <small style="margin-top:-1.4rem; display:block;">*Isi nama produk secara jelas untuk mempermudah proses identifikasi saat sourcing.</small>
+
+ <field name="descriptions" required="1"/>
+
+ <field name="quantity"/>
+ <small style="margin-top:-1.4rem; display:block;">*Jumlah unit produk yang dibutuhkan.</small>
+
+ <field name="note"/>
+ <small style="margin-top:-1.4rem; display:block;">*Catatan tambahan yang dapat membantu proses sourcing.</small>
+
+ <field name="budget"/>
+ <small style="margin-top:-1.4rem; display:block;">*Perkiraan atau batas anggaran harga yang diharapkan.</small>
+
+ <field name="attachment_type"/>
+ <small style="margin-top:-1.4rem; display:block;">*Lampiran referensi seperti gambar produk, katalog, atau dokumen spesifikasi yang mendukung proses sourcing.</small>
+
+ <field name="product_attachment_pdf"
+ filename="product_attachment_filename"
+ attrs="{'invisible':[('attachment_type','!=','pdf')]}"/>
+
+ <field name="product_attachment_img"
+ filename="product_attachment_filename"
+ widget="image"
+ attrs="{'invisible':[('attachment_type','!=','img')]}"/>
+
+ <field name="product_attachment_other"
+ filename="product_attachment_filename"
+ attrs="{'invisible':[('attachment_type','!=','other')]}"/>
+ </group>
+ </form>
+ </field>
+ </page>
+
+ <!-- MD EDIT -->
+ <!-- <page string="MD Lines" groups="indoteknik_custom.group_role_merchandiser">
+ <field name="line_md_edit_ids">
+ <tree
+ decoration-success="state in ('done', 'convert')"
+ decoration-danger="state=='cancel'"
+ decoration-warning="state=='sourcing'">
+ <field name="selected" widget="boolean_toggle"/>
+ <field name="code"/>
+ <field name="brand"/>
+ <field name="product_name"/>
+ <field name="descriptions"/>
+ <field name="quantity"/>
+ <field name="price"/>
+ <field name="vendor_id"/>
+ <field name="tax_id"/>
+ <field name="subtotal"/>
+ <field name="state"/>
+ <field name="sla"/>
+ <button name="action_convert_to_product"
+ string="Convert"
+ type="object"
+ icon="fa-exchange"
+ attrs="{'invisible': [('state', '!=', 'done')]}"/>
+ <button name="action_cancel_line"
+ string="Unavailable"
+ type="object"
+ class="btn-danger"
+ groups="indoteknik_custom.group_role_merchandiser"
+ attrs="{'invisible': [('state', '=', 'cancel')]}"/>
+ </tree>
+ <form string="MD Line">
+ <group>
+ <group>
+ <field name="code"/>
+ <field name="brand"/>
+ <field name="product_name"/>
+ <field name="descriptions"/>
+ <field name="quantity"/>
+ <field name="price"/>
+ <field name="vendor_id"/>
+ <field name="tax_id"/>
+ <field name="subtotal" readonly="1"/>
+ <field name="sla"/>
+ </group>
+ <group>
+ <field name="product_category"/>
+ <field name="product_type"/>
+ <field name="product_class" widget="many2many_tags"/>
+ <field name="note"/>
+ <field name="budget"/>
+ <field name="product_image" widget="image"/>
+ <field name="state" readonly="1" force_save="1"/>
+ <field name="reason" attrs="{'invisible': [('state', 'in', ['cancel', 'done', 'convert'])]}"/>
+ </group>
+ </group>
+ </form>
+ </field>
+ </page> -->
+
+ <!-- SALES VIEW -->
+ <page string="Product Line" groups="indoteknik_custom.group_role_sales" attrs="{'invisible': [('has_price_in_lines', '=', False)]}">
+ <field name="line_sales_view_ids">
+ <tree create="0" delete="0"
+ decoration-warning="state == 'sourcing'"
+ decoration-info="state == 'sent'"
+ decoration-success="state == 'approve'"
+ decoration-danger="state == 'cancel'">
+ <button name="action_reopen_cancel"
+ type="object"
+ string="Reopen"
+ class="btn-warning"
+ attrs="{
+ 'invisible': [
+ ('state','!=','cancel'),
+ ]
+ }"/>
+
+ <field name="product_id" readonly="1"/>
+ <field name="code" readonly="1"/>
+ <field name="brand" readonly="1"/>
+ <field name="product_name" readonly="1"/>
+ <field name="descriptions" readonly="1"/>
+ <field name="quantity" readonly="1"/>
+ <field name="price" readonly="1"/>
+ <field name="vendor_id" readonly="1"/>
+ <field name="tax_id" readonly="1"/>
+ <field name="subtotal" readonly="1"/>
+ <field name="state" readonly="1" widget="badge"
+ decoration-warning="state in ('sourcing','sent')"
+ decoration-info="state == 'approve'"
+ decoration-success="state == 'done'"
+ decoration-danger="state == 'cancel'"/>
+ </tree>
+ </field>
+ </page>
+
+ <page string="Cancel Reason" attrs="{'invisible': [('state', 'in', ['done'])]}">
+ <group>
+ <field name="cancel_reason"/>
+ </group>
+ </page>
+ </notebook>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids" widget="mail_followers"/>
+ <field name="message_ids" widget="mail_thread"/>
+ <field name="activity_ids" widget="mail_activity"/>
+ </div>
+ </form>
+ </field>
+ </record>
+
+ <record id="action_wizard_export_sjo_to_so" model="ir.actions.act_window">
+ <field name="name">Export Products to SO</field>
+ <field name="res_model">wizard.export.sjo.to.so</field>
+ <field name="view_mode">form</field>
+ <field name="view_id" ref="view_wizard_export_sjo_to_so_form"/>
+ <field name="target">new</field>
+ <field name="context">{'default_sjo_id': active_id}</field>
+ </record>
+
+ <record id="action_sourcing_job_order_sales" model="ir.actions.act_window">
+ <field name="name">Sourcing Job Orders</field>
+ <field name="res_model">sourcing.job.order</field>
+ <field name="view_mode">tree,form</field>
+ <field name="search_view_id" ref="view_sourcing_job_order_search"/>
+ <field name="context">{'search_default_by_create_uid': 1}</field>
+ <field name="groups_id" eval="[(4, ref('indoteknik_custom.group_role_sales'))]"/>
+ </record>
+
+ <menuitem id="menu_sourcing_job_order_sales"
+ name="Sourcing Job Orders"
+ parent="indoteknik_custom.menu_monitoring_in_sale"
+ action="action_sourcing_job_order_sales"
+ groups="indoteknik_custom.group_role_sales"
+ sequence="102"/>
+
+ <!-- MD Views -->
+ <record id="view_sourcing_job_order_line_search" model="ir.ui.view">
+ <field name="name">sourcing.job.order_line.search</field>
+ <field name="model">sourcing.job.order.line</field>
+ <field name="arch" type="xml">
+ <search string="Search MD Job">
+ <field name="state" string="Status"/>
+ <filter name="untaken"
+ string="Untaken"
+ domain="[('state', '=', 'draft')]" />
+ <filter name="my_job"
+ string="My Job"
+ domain="[('md_person_ids', '=', uid), ('state', '=', 'sourcing')]"/>
+ </search>
+ </field>
+ </record>
+
+ <record id="view_sjo_template_wizard" model="ir.ui.view">
+ <field name="name">sjo.template.wizard.form</field>
+ <field name="model">sourcing.job.order.line.template.wizard</field>
+ <field name="arch" type="xml">
+ <form string="Import Sourcing Job Order Line">
+ <group string="Download Template">
+ <field name="file" filename="filename" readonly="1"/>
+ <field name="filename" invisible="1"/>
+
+ <button name="action_generate_template"
+ string="Download Template"
+ type="object"
+ class="btn-secondary"/>
+ </group>
+
+ <separator string="Import Excel"/>
+
+ <group>
+ <field name="order_id" required="1"/>
+ <field name="excel_file" filename="excel_filename"/>
+ <field name="excel_filename" invisible="1"/>
+ </group>
+
+ <footer>
+ <button name="action_import_excel"
+ string="Import"
+ type="object"
+ class="btn-primary"/>
+
+ <button string="Close" special="cancel"/>
+ </footer>
+
+ </form>
+ </field>
+ </record>
+
+ <record id="action_import_sjo_line_server" model="ir.actions.server">
+ <field name="name">Import Line</field>
+ <field name="model_id" ref="model_sourcing_job_order_line"/>
+ <field name="binding_model_id" ref="model_sourcing_job_order_line"/>
+ <field name="binding_view_types">list</field>
+ <field name="state">code</field>
+
+ <field name="code">
+ action = {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Import SJO Line',
+ 'res_model': 'sourcing.job.order.line.template.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {'default_order_id': env.context.get('active_id')}
+ }
+ </field>
+ </record>
+
+ <record id="view_sourcing_job_order_line_tree" model="ir.ui.view">
+ <field name="name">sourcing.job.order.line.tree</field>
+ <field name="model">sourcing.job.order.line</field>
+ <field name="arch" type="xml">
+ <tree string="Sourcing Job Order"
+ decoration-warning="state=='sourcing'"
+ decoration-success="state== 'approve'"
+ decoration-danger="state=='cancel'"
+ decoration-info="state == 'sent'">
+ <field name="order_id" />
+ <field name="md_person_ids" widget="many2one_avatar_user"/>
+ <field name="show_salesperson" widget="many2one_avatar_user"/>
+ <field name="brand"/>
+ <field name="product_name"/>
+ <field name="quantity"/>
+ <field name="subtotal"/>
+ <field name="descriptions"/>
+ <field name="attachment_type" />
+ <field name="state" widget="badge"
+ decoration-muted="state=='draft'"
+ decoration-warning="state=='sourcing'"
+ decoration-success="state== 'approve'"
+ decoration-danger="state=='cancel'"/>
+ <field name="budget" optional="hide"/>
+ <field name="note" optional="hide"/>
+ </tree>
+ </field>
+ </record>
+
+ <record id="view_reject_sourcing_wizard" model="ir.ui.view">
+ <field name="name">reject.sourcing.wizard.form</field>
+ <field name="model">sourcing.reject.wizard</field>
+ <field name="arch" type="xml">
+ <form string="Alasan Reject">
+ <group>
+ <field name="reason" placeholder="Isi alasan reject..."/>
+ </group>
+ <footer>
+ <button name="action_confirm_reject"
+ type="object"
+ string="Confirm Reject"
+ class="btn-primary"/>
+ <button string="Cancel" special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="view_sjo_give_wizard_form" model="ir.ui.view">
+ <field name="name">sjo.give.wizard.form</field>
+ <field name="model">sjo.give.wizard</field>
+ <field name="arch" type="xml">
+ <form string="Give SJO Line">
+ <group>
+ <field name="md_id"/>
+ </group>
+ <footer>
+ <button string="Confirm"
+ type="object"
+ name="action_confirm"
+ class="btn-primary"/>
+ <button string="Cancel"
+ special="cancel"
+ class="btn-secondary"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="view_sjo_reject_give_wizard_form" model="ir.ui.view">
+ <field name="name">sjo.reject.give.wizard.form</field>
+ <field name="model">sjo.reject.give.wizard</field>
+ <field name="arch" type="xml">
+ <form string="Reject SJO Line">
+ <group>
+ <field name="reason" placeholder="Alasan reject..."/>
+ </group>
+ <footer>
+ <button name="action_confirm"
+ string="Confirm Reject"
+ type="object"
+ class="btn-danger"/>
+ <button string="Cancel"
+ class="btn-secondary"
+ special="cancel"/>
+ </footer>
+ </form>
+ </field>
+ </record>
+
+ <record id="view_sourcing_job_order_line_form" model="ir.ui.view">
+ <field name="name">sourcing.job.order.line.form</field>
+ <field name="model">sourcing.job.order.line</field>
+ <field name="arch" type="xml">
+ <form string="Sourcing Job" create="0">
+ <header>
+ <button name="action_take"
+ string="Take"
+ type="object"
+ class="btn-primary"
+ icon="fa-hand-paper-o"
+ attrs="{'invisible': [('state', '!=', 'draft')]}"/>
+
+ <button name="action_cancel"
+ string="Cancel"
+ type="object"
+ class="btn-secondary"
+ icon="fa-times"
+ groups="indoteknik_custom.group_role_merchandiser"
+ attrs="{'invisible': [('state', 'in', ['cancel', 'approve'])]}"/>
+
+ <button name="action_ask_approval"
+ string="Ask Approval"
+ type="object"
+ groups="indoteknik_custom.group_role_merchandiser"
+ attrs="{'invisible': [('state', '!=', 'sourcing')]}"/>
+
+ <button name="action_reject_approval"
+ string="Reject"
+ type="object"
+ groups="indoteknik_custom.group_role_sales"
+ attrs="{'invisible': [('state', '!=', 'sent')]}"/>
+
+ <button name="action_approve_approval"
+ string="Approve"
+ class="btn-primary"
+ type="object"
+ groups="indoteknik_custom.group_role_sales"
+ attrs="{'invisible': [('state', '!=', 'sent')]}"/>
+
+ <button name="action_open_give_wizard"
+ type="object"
+ string="Give to Other"
+ class="btn-primary"
+ attrs="{'invisible': ['|', '|', ('state', '!=', 'sourcing'), ('is_md_person', '=', False), ('is_given', '=', True)]}"/>
+
+ <button name="action_take_given"
+ type="object"
+ string="Take"
+ class="btn-primary"
+ attrs="{'invisible': ['|',
+ ('is_given','=',False),
+ ('is_receiver','=',False)
+ ]}"/>
+
+ <button name="action_open_reject_given_wizard"
+ type="object"
+ string="Reject"
+ attrs="{'invisible': ['|',
+ ('is_given','=',False),
+ ('is_receiver','=',False)
+ ]}"/>
+
+ <field name="state" widget="statusbar"
+ statusbar_visible="draft,sourcing,sent,approve,cancel" attrs="{'invisible': [('state', '!=', 'cancel')]}"/>
+
+ <field name="state" widget="statusbar"
+ statusbar_visible="draft,sourcing,sent,approve" attrs="{'invisible': [('state', '=', 'cancel')]}"/>
+ </header>
+ <sheet >
+ <widget name="web_ribbon"
+ title="COMPLETE"
+ bg_color="bg-success"
+ attrs="{'invisible': [('state', '!=', 'approve')]}"/>
+
+ <widget name="web_ribbon"
+ title="CANCEL"
+ bg_color="bg-danger"
+ attrs="{'invisible': [('state', '!=', 'cancel')]}"/>
+ <h1>
+ <field name="order_id" readonly="1"/>
+ </h1>
+ <group col="2">
+ <group>
+ <separator string="MD Edit"/>
+ <field name="is_md_person" invisible="1"/>
+ <field name="is_given" invisible="1"/>
+ <field name="is_receiver" invisible="1"/>
+ <field name="md_person_ids" widget="many2one_avatar_user" readonly="1"/>
+ <field name="brand_id" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ <field name="product_id" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ <field name="descriptions_md" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ <field name="code" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ <field name="product_name_md" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ <field name="price" required="1" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ <field name="now_price" force_save="1" readonly="1"/>
+ <field name="last_updated_price" force_save="1"/>
+ <field name="vendor_id" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ <field name="tax_id" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ <field name="subtotal" readonly="1" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ <field name="product_category" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ <field name="product_type" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ <field name="product_class" widget="many2many_tags" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ </group>
+
+ <group>
+ <separator string="Sales Input"/>
+ <field name="create_uid" widget="many2one_avatar_user" readonly="1"/>
+ <field name="show_salesperson" widget="many2one_avatar_user" readonly="1"/>
+ <field name="so_id"/>
+ <field name="so_state" widget="badge" readonly="1"
+ decoration-success="so_state == 'sale'"
+ decoration-info="so_state == 'draft'"
+ decoration-danger="so_state == 'cancel'"/>
+ <field name="brand" readonly="1"/>
+ <field name="product_name" readonly="1"/>
+ <field name="descriptions" readonly="1"/>
+ <field name="quantity" readonly="1"/>
+ <field name="sla" readonly="1"/>
+ <field name="note" readonly="1"/>
+ <field name="budget" readonly="1"/>
+ <field name="attachment_type" readonly="1"/>
+
+ <field name="product_attachment_pdf"
+ filename="product_attachment_filename"
+ widget="pdf_viewer"
+ attrs="{'invisible':[('attachment_type','!=','pdf')]}"/>
+
+ <field name="product_attachment_img"
+ filename="product_attachment_filename"
+ widget="image"
+ attrs="{'invisible':[('attachment_type','!=','img')]}"/>
+
+ <field name="product_attachment_other"
+ filename="product_attachment_filename"
+ attrs="{'invisible':[('attachment_type','!=','other')]}"/>
+ </group>
+ </group>
+ <notebook>
+ <page string="Cancel Reason" attrs="{'invisible': [('state', 'in', ['approve'])]}">
+ <group>
+ <field name="reason" attrs="{'readonly': [('state', 'in', ['sent', 'cancel', 'approve'])]}"/>
+ </group>
+ </page>
+ </notebook>
+ </sheet>
+ <div class="oe_chatter">
+ <field name="message_follower_ids" widget="mail_followers"/>
+ <field name="message_ids" widget="mail_thread"/>
+ <field name="activity_ids" widget="mail_activity"/>
+ </div>
+ </form>
+ </field>
+ </record>
+
+ <record id="action_sourcing_job_order_line_multi_take" model="ir.actions.server">
+ <field name="name">Take Selected Jobs</field>
+ <field name="model_id" ref="model_sourcing_job_order_line"/>
+ <field name="binding_model_id" ref="model_sourcing_job_order_line"/>
+ <field name="binding_type">action</field>
+ <field name="state">code</field>
+ <field name="code">action = records.action_multi_take()</field>
+ <field name="groups_id" eval="[(4, ref('indoteknik_custom.group_role_merchandiser'))]"/>
+ </record>
+
+ <record id="action_sourcing_job_order_md" model="ir.actions.act_window">
+ <field name="name">Sourcing Job Orders</field>
+ <field name="res_model">sourcing.job.order.line</field>
+ <field name="view_mode">tree,form</field>
+ <field name="search_view_id" ref="view_sourcing_job_order_line_search"/>
+ <field name="context">{
+ 'search_default_untaken': 1,
+ 'search_default_my_job': 1,
+ 'order': "CASE state WHEN 'draft' THEN 1 WHEN 'sourcing' THEN 2 WHEN 'sent' THEN 3 WHEN 'approve' THEN 4 WHEN 'cancel' THEN 5 END, id desc"
+ }
+ </field>
+ <field name="groups_id" eval="[(4, ref('indoteknik_custom.group_role_merchandiser'))]"/>
+ </record>
+
+ <menuitem id="menu_md_root"
+ name="MD"
+ parent="crm.crm_menu_root"
+ sequence="80"/>
+
+ <menuitem id="menu_sourcing_job_order_md"
+ name="Sourcing Job"
+ parent="indoteknik_custom.menu_monitoring_in_sale"
+ action="action_sourcing_job_order_md"
+ groups="indoteknik_custom.group_role_merchandiser"
+ sequence="91"/>
+
+ <!-- Approval Per line sales -->
+ <record id="view_sjo_line_approval_search" model="ir.ui.view">
+ <field name="name">sourcing.job.order.line.approval.search</field>
+ <field name="model">sourcing.job.order.line</field>
+ <field name="arch" type="xml">
+ <search string="Search Sourcing Approval">
+
+ <field name="product_name"/>
+ <field name="brand"/>
+ <field name="so_id"/>
+ <field name="show_salesperson"/>
+
+ <filter name="filter_sent"
+ string="Sent"
+ domain="[('state','=','sent')]"/>
+
+ <group expand="0" string="Group By">
+ <filter name="group_so"
+ string="Sales Order"
+ context="{'group_by':'so_id'}"/>
+ </group>
+
+ <filter name="filter_my_line"
+ string="My Line"
+ domain="[('show_salesperson','=',uid)]"/>
+
+ </search>
+ </field>
+ </record>
+
+ <record id="view_sjo_line_approval_tree" model="ir.ui.view">
+ <field name="name">sourcing.job.order.line.approval.tree</field>
+ <field name="model">sourcing.job.order.line</field>
+ <field name="arch" type="xml">
+ <tree string="Approval Sourcing Line"
+ create="0"
+ edit="0"
+ delete="0"
+ decoration-warning="state in ('sourcing','sent')"
+ decoration-success="state == 'approve'"
+ decoration-danger="state == 'cancel'">
+ <field name="order_id"/>
+ <field name="md_person_ids" widget="many2one_avatar_user" readonly="1"/>
+ <field name="so_id"/>
+ <field name="show_salesperson" widget="many2one_avatar_user"/>
+ <field name="so_state" widget="badge"
+ decoration-success="so_state == 'sale'"
+ decoration-info="so_state == 'draft'"
+ decoration-danger="so_state == 'cancel'"/>
+ <field name="code"/>
+ <field name="brand"/>
+ <field name="product_name"/>
+ <field name="quantity"/>
+ <field name="price"/>
+ <field name="vendor_id"/>
+ <field name="subtotal"/>
+
+ <field name="state"
+ widget="badge"
+ decoration-warning="state in ('sourcing','sent')"
+ decoration-success="state == 'approve'"
+ decoration-danger="state == 'cancel'"/>
+ </tree>
+ </field>
+ </record>
+
+ <record id="action_multi_approve_sjo_line" model="ir.actions.server">
+ <field name="name">Approve Selected Lines</field>
+ <field name="model_id" ref="model_sourcing_job_order_line"/>
+ <field name="binding_model_id" ref="model_sourcing_job_order_line"/>
+ <field name="binding_view_types">list</field>
+ <field name="state">code</field>
+ <field name="groups_id" eval="[(4, ref('indoteknik_custom.group_role_sales'))]"/>
+ <field name="code">
+ records.action_multi_approve()
+ </field>
+ </record>
+
+ <record id="action_sourcing_line_approval" model="ir.actions.act_window">
+ <field name="name">Approval Sourcing Line</field>
+ <field name="res_model">sourcing.job.order.line</field>
+ <field name="view_mode">tree,form</field>
+ <field name="search_view_id" ref="view_sjo_line_approval_search"/>
+ <field name="domain">[('state','in',('sent','approve'))]</field>
+ <field name="context">
+ {
+ 'search_default_filter_sent': 1,
+ 'search_default_filter_my_line': 1,
+ 'search_default_group_so': 1
+ }
+ </field>
+ </record>
+
+ <menuitem id="menu_sourcing_line_approval"
+ name="Approval Sourcing Line"
+ parent="indoteknik_custom.menu_monitoring_in_sale"
+ action="action_sourcing_line_approval"
+ sequence="110"
+ groups="indoteknik_custom.group_role_sales"/>
+</odoo>