diff --git a/jikimo_frontend/static/src/js/custom_form_status_indicator.js b/jikimo_frontend/static/src/js/custom_form_status_indicator.js index 912c8efa..46e3ad70 100644 --- a/jikimo_frontend/static/src/js/custom_form_status_indicator.js +++ b/jikimo_frontend/static/src/js/custom_form_status_indicator.js @@ -53,6 +53,23 @@ const tableRequiredList = [ ] patch(FormStatusIndicator.prototype, 'jikimo_frontend.FormStatusIndicator', { + setup() { + owl.onMounted(() => { + try { + const dom = this.__owl__.bdom.el + const buttonsDom = $(dom).find('.o_form_status_indicator_buttons ') + if (buttonsDom) { + const dom1 = buttonsDom.children('.o_form_button_save') + const dom2 = buttonsDom.children('.o_form_button_cancel') + dom1.append('保存') + dom2.append('取消') + } + } catch (e) { + console.log(e) + } + + }); + }, // 你可以重写或者添加一些方法和属性 async _onDiscardChanges() { // var self = this; @@ -183,17 +200,6 @@ patch(ListRenderer.prototype, 'jikimo_frontend.ListRenderer', { // }) $(function () { - document.addEventListener('click', function () { - const dom = $('.o_form_status_indicator_buttons ') - if (dom) { - const dom1 = dom.children().eq(0) - const dom2 = dom.children().eq(1) - if (!dom1.text()) { - dom1.append('保存') - dom2.append('取消') - } - } - }) function customRequired() { let timer = null diff --git a/sf_machine_connect/__manifest__.py b/sf_machine_connect/__manifest__.py index db7ae467..ac8aef64 100644 --- a/sf_machine_connect/__manifest__.py +++ b/sf_machine_connect/__manifest__.py @@ -30,6 +30,7 @@ 'views/machine_info_present.xml', 'views/delivery_record.xml', 'views/res_config_settings_views.xml', + 'views/maintenance_views.xml', ], 'assets': { diff --git a/sf_machine_connect/controllers/controllers.py b/sf_machine_connect/controllers/controllers.py index ba8139d0..4f926a5b 100644 --- a/sf_machine_connect/controllers/controllers.py +++ b/sf_machine_connect/controllers/controllers.py @@ -1,11 +1,45 @@ # -*- coding: utf-8 -*- +import re import ast import json import logging +from datetime import datetime from odoo import http from odoo.http import request +def convert_to_seconds(time_str): + # 修改正则表达式,使 H、M、S 部分可选 + + pattern = r"(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?" + match = re.match(pattern, time_str) + + if match: + # 提取各时间单位,如果某个单位缺失则默认设为0 + hours = int(match.group(1)) if match.group(1) else 0 + minutes = int(match.group(2)) if match.group(2) else 0 + seconds = int(match.group(3)) if match.group(3) else 0 + + # 计算总秒数 + total_seconds = hours * 3600 + minutes * 60 + seconds + if total_seconds == 0: + # return None + pattern = r"(?:(\d+)小时)?(?:(\d+)分钟)?(?:(\d+)秒)?" + match = re.match(pattern, time_str) + if match: + # 提取各时间单位,如果某个单位缺失则默认设为0 + hours = int(match.group(1)) if match.group(1) else 0 + minutes = int(match.group(2)) if match.group(2) else 0 + seconds = int(match.group(3)) if match.group(3) else 0 + + # 计算总秒数 + total_seconds = hours * 3600 + minutes * 60 + seconds + return total_seconds + else: + return None + return total_seconds + + class Sf_Dashboard_Connect(http.Controller): @http.route('/api/get_machine_datas/list', type='http', auth='public', methods=['GET', 'POST'], csrf=False, @@ -18,6 +52,11 @@ class Sf_Dashboard_Connect(http.Controller): """ res = {'status': 1, 'message': '成功', 'data': []} logging.info('前端请求机床数据的参数为:%s' % kw) + + # 获取当前时间的时间戳 + current_timestamp = datetime.now().timestamp() + print(current_timestamp) + # tem_list = [ # "XT-GNJC-WZZX-X800-Y550-Z550-T24-A5-1", "XT-GNJC-LSZX-X800-Y550-Z550-T24-A3-3", # "XT-GNJC-LSZX-X800-Y550-Z550-T24-A3-4", "XT-GNJC-LSZX-X800-Y550-Z550-T24-A3-5", @@ -33,10 +72,24 @@ class Sf_Dashboard_Connect(http.Controller): machine_list = ast.literal_eval(kw['machine_list']) for item in machine_list: machine_data = equipment_obj.search([('code', '=', item)]) + + # 机床上线时间段 + first_online_duration = current_timestamp - int(machine_data.first_online_time.timestamp()) + + power_off_time = None + power_off_rate = None + if machine_data.machine_power_on_time: + power_off_time = first_online_duration - convert_to_seconds(machine_data.machine_power_on_time) + power_off_rate = round((power_off_time / first_online_duration), 3) + else: + power_off_time = False + power_off_rate = False if machine_data: res['data'].append({ + 'active': machine_data.status, 'id': machine_data.id, 'name': machine_data.name, + 'brand': machine_data.type_id.name, 'code': machine_data.code, 'status': machine_data.status, 'run_status': machine_data.run_status, @@ -88,6 +141,16 @@ class Sf_Dashboard_Connect(http.Controller): 'alarm_time': machine_data.alarm_time, 'alarm_msg': machine_data.alarm_msg, 'clear_time': machine_data.clear_time, + # 计算出来的数据 + # 开动率:运行时间/通电时间 + 'run_rate': machine_data.run_rate, + # 关机时长:初次上线时间 - 通电时间 + 'power_off_time': power_off_time, + # 关机率:关机时长/初次上线时间 + 'power_off_rate': power_off_rate, + 'first_online_duration': first_online_duration, + # 停机时间:关机时间 - 运行时间 + # 停机时长:关机时间 - 初次上线时间 }) return json.JSONEncoder().encode(res) @@ -96,3 +159,126 @@ class Sf_Dashboard_Connect(http.Controller): res['status'] = -1 res['message'] = '前端请求机床数据失败,原因:%s' % e return json.JSONEncoder().encode(res) + + # @http.route('/api/logs/list', type='http', auth='public', methods=['GET', 'POST'], csrf=False, + # cors="*") + # def logs_list(self, **kw): + # """ + # 拿到日志数据返回给大屏展示 + # :param kw: + # :return: + # """ + # res = {'status': 1, 'message': '成功', 'data': []} + # logging.info('前端请求日志数据的参数为:%s' % kw) + # + # try: + # # 获取请求的日志数据 + # logs_obj = request.env['maintenance.equipment.oee.log.detail'].sudo() + # # 获取请求的机床数据 + # machine_list = ast.literal_eval(kw['machine_list']) + # begin_time_str = kw['begin_time'].strip('"') + # end_time_str = kw['end_time'].strip('"') + # + # begin_time = datetime.strptime(begin_time_str, '%Y-%m-%d %H:%M:%S') + # end_time = datetime.strptime(end_time_str, '%Y-%m-%d %H:%M:%S') + # + # print('begin_time: %s' % begin_time) + # for item in machine_list: + # log_datas = logs_obj.search( + # [('equipment_code', '=', item), ('time', '>=', begin_time), ('time', '<=', end_time)]) + # print('log_datas: %s' % log_datas) + # for log_data in log_datas: + # res['data'].append({ + # 'equipment_code': log_data.equipment_code, + # 'time': log_data.time.strftime('%Y-%m-%d %H:%M:%S'), + # 'state': log_data.state + # + # }) + # + # return json.JSONEncoder().encode(res) + # + # except Exception as e: + # logging.info('前端请求日志数据失败,原因:%s' % e) + # res['status'] = -1 + # res['message'] = '前端请求日志数据失败,原因:%s' % e + # return json.JSONEncoder().encode(res) + + @http.route('/api/logs/list', type='http', auth='public', methods=['GET', 'POST'], csrf=False, cors="*") + def logs_list(self, **kw): + """ + 拿到日志数据返回给大屏展示 + :param kw: + :return: + """ + res = {'status': 1, 'message': '成功', 'data': {}} + logging.info('前端请求日志数据的参数为:%s' % kw) + + try: + # 获取请求的日志数据 + logs_obj = request.env['maintenance.equipment.oee.log.detail'].sudo() + # 获取请求的机床数据 + machine_list = ast.literal_eval(kw['machine_list']) + begin_time_str = kw['begin_time'].strip('"') + end_time_str = kw['end_time'].strip('"') + + begin_time = datetime.strptime(begin_time_str, '%Y-%m-%d %H:%M:%S') + end_time = datetime.strptime(end_time_str, '%Y-%m-%d %H:%M:%S') + + print('begin_time: %s' % begin_time) + + for item in machine_list: + log_datas = logs_obj.search( + [('equipment_code', '=', item), ('time', '>=', begin_time), ('time', '<=', end_time)]) + print('log_datas: %s' % log_datas) + + # 将数据按照 equipment_code 进行分组 + if item not in res['data']: + res['data'][item] = [] + + for log_data in log_datas: + res['data'][item].append({ + 'time': log_data.time.strftime('%Y-%m-%d %H:%M:%S'), + 'state': log_data.state, + 'production_name': log_data.production_name, + }) + + return json.dumps(res) # 注意使用 json.dumps 而不是直接用 json.JSONEncoder().encode() + + except Exception as e: + logging.info('前端请求日志数据失败,原因:%s' % e) + res['status'] = -1 + res['message'] = '前端请求日志数据失败,原因:%s' % e + return json.dumps(res) + + # 返回CNC机床列表 + @http.route('/api/CNCList', type='http', auth='public', methods=['GET', 'POST'], csrf=False, + cors="*") + def CNCList(self, **kw): + """ + 获取CNC机床列表 + :param kw: + :return: + """ + + # logging.info('CNCList:%s' % kw) + try: + res = {'Succeed': True} + # cnc_list = request.env['sf.cnc.equipment'].sudo().search([]) + # cnc_list = ["XT-GNJC-WZZX-X800-Y550-Z550-T24-A5-1", "XT-GNJC-LSZX-X800-Y550-Z550-T24-A3-3", + # "XT-GNJC-LSZX-X800-Y550-Z550-T24-A3-4", "XT-GNJC-LSZX-X800-Y550-Z550-T24-A3-5", + # "XT-GNJC-LSZX-X800-Y550-Z550-T24-A3-6", "XT-GNJC-LSZX-X800-Y550-Z550-T24-A3-7", + # "XT-GNJC-LSZX-X800-Y550-Z550-T24-A3-8", "XT-GNJC-WZZX-X800-Y550-Z550-T24-A5-2", + # "XT-GNJC-GSZG-X600-Y400-Z350-T21-A3-9", "XT-GNJC-GSZG-X600-Y400-Z350-T21-A3-10", + # "XT-GNJC-GSZG-X600-Y400-Z350-T21-A3-11", "XT-GNJC-GSZG-X600-Y400-Z350-T21-A3-12", + # "XT-GNJC-GSZG-X600-Y400-Z350-T21-A3-13", "XT-GNJC-GSZG-X600-Y400-Z350-T21-A3-14"] + + cnc_list_obj = request.env['maintenance.equipment'].sudo().search( + [('function_type', '!=', False), ('active', '=', True)]) + cnc_list = list(map(lambda x: x.code, cnc_list_obj)) + print('cnc_list: %s' % cnc_list) + res['CNCList'] = cnc_list + + except Exception as e: + res = {'Succeed': False, 'ErrorCode': 202, 'Error': e} + logging.info('CNCList error:%s' % e) + return json.JSONEncoder().encode(res) diff --git a/sf_machine_connect/models/__init__.py b/sf_machine_connect/models/__init__.py index ef61fd83..01ae60ae 100644 --- a/sf_machine_connect/models/__init__.py +++ b/sf_machine_connect/models/__init__.py @@ -2,3 +2,4 @@ from . import ftp_client from . import ftp_operate from . import py2opcua from . import res_config_setting +from . import mrp_workorder diff --git a/sf_machine_connect/models/ftp_client.py b/sf_machine_connect/models/ftp_client.py index 1e09f665..1d56424f 100644 --- a/sf_machine_connect/models/ftp_client.py +++ b/sf_machine_connect/models/ftp_client.py @@ -121,6 +121,13 @@ class Machine_ftp(models.Model): """ _inherit = 'maintenance.equipment' + # 机床首次上线时间(默认取值2024年08月01日零点) + + def _get_default_online_time(self): + return datetime(2024, 1, 1, 0, 0, 0) + + first_online_time = fields.Datetime(string='首次上线时间', default=_get_default_online_time) + # workorder_ids = fields.One2many('mrp.workorder', 'machine_tool_id', string='工单') # # 机床配置项目 @@ -275,7 +282,28 @@ class Machine_ftp(models.Model): alarm_msg = fields.Char('故障报警信息', readonly=True) clear_time = fields.Char('故障消除时间(复原时间)', readonly=True) - # 当前程序名, 机床累计运行时间, 机床系统日期, 机床系统时间, 当前刀具号, 机床循环时间 + # # 开动率 + run_rate = fields.Char('开动率', readonly=True) + + # 同步CNC设备到oee + def sync_oee(self): + """ + 同步CNC设备到oee + :return: + """ + for record in self: + record.ensure_one() + cnc_oee_dict = { + 'equipment_id': record.id, + 'type_id': record.type_id.id, + 'machine_tool_picture': record.machine_tool_picture, + 'equipment_code': record.code, + 'function_type': record.function_type, + } + if self.env['maintenance.equipment.oee.logs'].search([('equipment_id', '=', record.id)]): + self.env['maintenance.equipment.oee.logs'].write(cnc_oee_dict) + else: + self.env['maintenance.equipment.oee.logs'].create(cnc_oee_dict) class WorkCenterBarcode(models.Model): diff --git a/sf_machine_connect/models/mrp_workorder.py b/sf_machine_connect/models/mrp_workorder.py new file mode 100644 index 00000000..21cc96ac --- /dev/null +++ b/sf_machine_connect/models/mrp_workorder.py @@ -0,0 +1,38 @@ +import re + +from odoo import fields, models, api + + +class ResMrpWorkOrder(models.Model): + _inherit = 'mrp.workorder' + + mixed_search_field = fields.Char(string='坯料产品名称/RFID') + + @api.model + def web_read_group(self, domain, fields, groupby, limit=None, offset=0, orderby=False, + lazy=True, expand=False, expand_limit=None, expand_orderby=False): + domain = domain or [] + for index, item in enumerate(domain): + if isinstance(item, list): + if item[0] == 'mixed_search_field': + if self._is_rfid_code(item[2]): + domain[index] = ['rfid_code', item[1], item[2]] + else: + domain[index] = ['product_tmpl_name', item[1], item[2]] + + return super(ResMrpWorkOrder, self).web_read_group(domain, fields, groupby, limit=limit, offset=offset, orderby=orderby, + lazy=lazy, expand=expand, expand_limit=expand_limit, expand_orderby=expand_orderby) + + def _is_rfid_code(self, tag): + """ + 判断是否是rfid_code + """ + # 基于长度判断(假设RFID标签长度为10到16个字符) + if not 10 <= len(tag) <= 16: + return False + + # 基于字符集判断(仅包含数字和字母) + if not re.match("^[0-9]*$", tag): + return False + + return True \ No newline at end of file diff --git a/sf_machine_connect/views/WorkCenterBarcodes.xml b/sf_machine_connect/views/WorkCenterBarcodes.xml index 9e5d3982..97fee70e 100644 --- a/sf_machine_connect/views/WorkCenterBarcodes.xml +++ b/sf_machine_connect/views/WorkCenterBarcodes.xml @@ -26,6 +26,7 @@ + diff --git a/sf_machine_connect/views/machine_monitor.xml b/sf_machine_connect/views/machine_monitor.xml index c6964659..5a05778f 100644 --- a/sf_machine_connect/views/machine_monitor.xml +++ b/sf_machine_connect/views/machine_monitor.xml @@ -18,6 +18,7 @@ + diff --git a/sf_machine_connect/views/maintenance_views.xml b/sf_machine_connect/views/maintenance_views.xml new file mode 100644 index 00000000..82c5aff4 --- /dev/null +++ b/sf_machine_connect/views/maintenance_views.xml @@ -0,0 +1,17 @@ + + + + + sf.machine.hr.equipment.view.tree.inherit + maintenance.equipment + + + +
+
+
+
+
+ +
\ No newline at end of file diff --git a/sf_maintenance/models/sf_maintenance_oee.py b/sf_maintenance/models/sf_maintenance_oee.py index 41301410..9a6aaba8 100644 --- a/sf_maintenance/models/sf_maintenance_oee.py +++ b/sf_maintenance/models/sf_maintenance_oee.py @@ -41,29 +41,32 @@ class SfMaintenanceEquipmentOEELog(models.Model): _name = 'maintenance.equipment.oee.logs' _description = '设备运行日志' - equipment_id = fields.Many2one('maintenance.equipment', '机台号') - equipment_code = fields.Char('设备编码') + equipment_id = fields.Many2one('maintenance.equipment', '机台号', readonly='True') + equipment_code = fields.Char('设备编码', readonly='True') name = fields.Char('设备名称', readonly='True') + function_type = fields.Selection( + [("ZXJGZX", "钻铣加工中心"), ("CXJGZX", "车削加工中心"), ("FHJGZX", "复合加工中心")], + default="", string="功能类型") machine_tool_picture = fields.Binary('设备图片') - type_id = fields.Many2one('sf.machine_tool.type', '品牌型号') + type_id = fields.Many2one('sf.machine_tool.type', '品牌型号', reaonly='True') state = fields.Selection([("加工", "加工"), ("关机", "关机"), ("待机", "待机"), ("故障", "故障"), ("检修", "检修"), ("保养", "保养")], default="", string="实时状态") - online_time = fields.Char('开机时长') + online_time = fields.Char('开机时长', reaonly='True') - offline_time = fields.Char('关机时长') - offline_nums = fields.Integer('关机次数') + offline_time = fields.Char('关机时长', reaonly='True') + offline_nums = fields.Integer('关机次数', reaonly='True') # 待机时长 - idle_time = fields.Char('待机时长') + idle_time = fields.Char('待机时长', reaonly='True') # 待机率 - idle_rate = fields.Char('待机率') + idle_rate = fields.Char('待机率', reaonly='True') - work_time = fields.Char('加工时长') - work_rate = fields.Char('可用率') - fault_time = fields.Char('故障时长') - fault_rate = fields.Char('故障率') - fault_nums = fields.Integer('故障次数') + work_time = fields.Char('加工时长', reaonly='True') + work_rate = fields.Char('可用率', reaonly='True') + fault_time = fields.Char('故障时长', reaonly='True') + fault_rate = fields.Char('故障率', reaonly='True') + fault_nums = fields.Integer('故障次数', reaonly='True') detail_ids = fields.One2many('maintenance.equipment.oee.log.detail', 'log_id', string='日志详情') @@ -81,12 +84,15 @@ class SfMaintenanceEquipmentOEELog(models.Model): class SfMaintenanceEquipmentOEELogDetail(models.Model): _name = 'maintenance.equipment.oee.log.detail' _description = '设备运行日志详情' + _order = 'time desc' - sequence = fields.Integer('序号') + # sequence = fields.Integer('序号', related='id') time = fields.Datetime('时间') state = fields.Selection([("加工", "加工"), ("关机", "关机"), ("待机", "待机"), ("故障", "故障"), ("检修", "检修"), ("保养", "保养")], default="", string="事件/状态") - production_id = fields.Many2one('mrp.production', '加工工单') + production_name = fields.Char('加工工单') log_id = fields.Many2one('maintenance.equipment.oee.logs', '日志') + # equipment_code = fields.Char('设备编码', related='log_id.equipment_code') + equipment_code = fields.Char('设备编码', readonly='True') diff --git a/sf_maintenance/views/maintenance_logs_views.xml b/sf_maintenance/views/maintenance_logs_views.xml index 0d172285..b3922595 100644 --- a/sf_maintenance/views/maintenance_logs_views.xml +++ b/sf_maintenance/views/maintenance_logs_views.xml @@ -159,6 +159,8 @@ + +
@@ -202,10 +204,10 @@ - + - + @@ -219,10 +221,10 @@ - + - + @@ -263,10 +265,10 @@ maintenance.equipment.oee.log.detail - + - + @@ -280,10 +282,10 @@ - + - + diff --git a/sf_manufacturing/__manifest__.py b/sf_manufacturing/__manifest__.py index 39da4482..e84b34cc 100644 --- a/sf_manufacturing/__manifest__.py +++ b/sf_manufacturing/__manifest__.py @@ -15,12 +15,14 @@ 'data/stock_data.xml', 'data/empty_racks_data.xml', 'data/panel_data.xml', + 'data/agv_scheduling_data.xml', 'security/group_security.xml', 'security/ir.model.access.csv', 'wizard/workpiece_delivery_views.xml', 'wizard/rework_wizard_views.xml', 'wizard/production_wizard_views.xml', 'views/mrp_views_menus.xml', + 'views/agv_scheduling_views.xml', 'views/stock_lot_views.xml', 'views/mrp_production_addional_change.xml', 'views/mrp_routing_workcenter_view.xml', @@ -30,7 +32,6 @@ 'views/model_type_view.xml', 'views/agv_setting_views.xml', 'views/sf_maintenance_equipment.xml', - ], 'assets': { @@ -40,7 +41,9 @@ 'web.assets_backend': [ 'sf_manufacturing/static/src/xml/kanban_change.xml', 'sf_manufacturing/static/src/js/kanban_change.js', - 'sf_manufacturing/static/src/scss/kanban_change.scss' + 'sf_manufacturing/static/src/scss/kanban_change.scss', + 'sf_manufacturing/static/src/xml/button_show_on_tree.xml', + 'sf_manufacturing/static/src/js/workpiece_delivery_wizard_confirm.js', ] }, diff --git a/sf_manufacturing/controllers/controllers.py b/sf_manufacturing/controllers/controllers.py index f3597093..6f200926 100644 --- a/sf_manufacturing/controllers/controllers.py +++ b/sf_manufacturing/controllers/controllers.py @@ -2,6 +2,8 @@ import logging import json from datetime import datetime + +from odoo.addons.sf_manufacturing.models.agv_scheduling import RepeatTaskException from odoo import http from odoo.http import request @@ -386,7 +388,7 @@ class Manufacturing_Connect(http.Controller): ret = json.loads(datas) request.env['center_control.interface.log'].sudo().create( {'content': ret, 'name': 'AutoDeviceApi/LocationChange'}) - logging.info('LocationChange_ret===========:%s' % ret) + logging.info('库位变更LocationChange_ret:%s' % ret) RfidCode = ret['RfidCode'] ChangeType = ret['ChangeType'] OldDeciveId = ret['OldDeciveId'] @@ -396,34 +398,79 @@ class Manufacturing_Connect(http.Controller): OldDeciveStart = ret['OldDeciveStart'] OldDeciveEnd = ret['OldDeciveEnd'] - temp_val_sn_id = None - old_localtion = None - # if ChangeType == 'Part' or ChangeType == 'Tool': - stock_lot_obj = request.env['stock.lot'].sudo().search( - [('rfid', '=', RfidCode)], limit=1) - logging.info('stock_lot_obj===========:%s' % stock_lot_obj) - if not stock_lot_obj: - res = {'Succeed': False, 'ErrorCode': 202, 'Error': '未根据RfidCode找到该产品'} - return json.JSONEncoder().encode(res) - if OldPosition: - old_localtion = request.env['sf.shelf.location'].sudo().search( - [('barcode', '=', OldPosition)], limit=1) - logging.info('old_localtion===========:%s' % old_localtion) - new_localtion = request.env['sf.shelf.location'].sudo().search( - [('barcode', '=', NewPosition)], limit=1) - logging.info('new_localtion===========:%s' % new_localtion) - if not new_localtion: - res = {'Succeed': False, 'ErrorCode': 202, 'Error': '没有该目标位置'} - return json.JSONEncoder().encode(res) - if old_localtion: - temp_val_sn_id = old_localtion.product_sn_id - logging.info('temp_val_sn_id===========:%s' % temp_val_sn_id) - old_localtion.product_sn_id = None - new_localtion.product_sn_id = temp_val_sn_id - logging.info('====1======') - else: - new_localtion.product_sn_id = stock_lot_obj.id - logging.info('=====2======') + if ChangeType == 'Part': + temp_val_sn_id = None + old_localtion = None + # if ChangeType == 'Part' or ChangeType == 'Tool': + stock_lot_obj = request.env['stock.lot'].sudo().search( + [('rfid', '=', RfidCode)], limit=1) + logging.info('stock_lot_obj===========:%s' % stock_lot_obj) + if not stock_lot_obj: + res = {'Succeed': False, 'ErrorCode': 202, 'Error': '未根据RfidCode找到该产品'} + return json.JSONEncoder().encode(res) + if OldPosition: + old_localtion = request.env['sf.shelf.location'].sudo().search( + [('barcode', '=', OldPosition)], limit=1) + logging.info('old_localtion===========:%s' % old_localtion) + new_localtion = request.env['sf.shelf.location'].sudo().search( + [('barcode', '=', NewPosition)], limit=1) + logging.info('new_localtion===========:%s' % new_localtion) + if not new_localtion: + res = {'Succeed': False, 'ErrorCode': 202, 'Error': '没有该目标位置'} + return json.JSONEncoder().encode(res) + if old_localtion: + temp_val_sn_id = old_localtion.product_sn_id + logging.info('temp_val_sn_id===========:%s' % temp_val_sn_id) + old_localtion.product_sn_id = None + new_localtion.product_sn_id = temp_val_sn_id + logging.info('====1======') + else: + new_localtion.product_sn_id = stock_lot_obj.id + logging.info('=====2======') + elif ChangeType == 'Tool': + # 对功能刀具库位变更信息进行更改 + def write_tool(DeciveId): + if 'Tool' in DeciveId: + shelfinfo = list(filter(lambda x: x.get('DeviceId') == DeciveId, + request.env['sf.shelf.location'].sudo().get_sf_shelf_location_info( + DeciveId))) + total_data = request.env['sf.shelf.location.datasync'].sudo().get_total_data() + for item in shelfinfo: + shelf_barcode = request.env['sf.shelf.location.datasync'].sudo().find_our_code( + total_data, item['Postion']) + location_id = request.env['sf.shelf.location'].sudo().search( + [('barcode', '=', shelf_barcode)], + limit=1) + if location_id: + # 如果是线边刀库信息,则对功能刀具移动生成记录 + if 'Tool' in item['Postion']: + tool = request.env['sf.functional.cutting.tool.entity'].sudo().search( + [('rfid', '=', item['RfidCode']), ('functional_tool_status', '!=', '已拆除')]) + tool.sudo().tool_in_out_stock_location(location_id) + if tool: + location_id.product_sn_id = tool.barcode_id.id + # 修改功能刀具状态 + tool_state = {'Nomal': '正常', 'Warning': '报警'} + if tool_state.get(item.get('State')): + if tool_state.get(item.get('State')) != tool.functional_tool_status: + tool.write({ + 'functional_tool_status': tool_state.get(item['State']) + }) + else: + location_id.product_sn_id = False + logging.info('货架已获取信息:%s' % item) + else: + equipment_id = request.env['maintenance.equipment'].sudo().search([('name', '=', DeciveId)]) + if equipment_id: + equipment_id.sudo().register_equipment_tool() + else: + res_1 = {'Succeed': False, 'ErrorCode': 202, 'Error': f'设备【{DeciveId}】不存在'} + return json.JSONEncoder().encode(res_1) + + if OldDeciveId: + write_tool(OldDeciveId) + elif NewDeciveId: + write_tool(NewDeciveId) except Exception as e: res = {'Succeed': False, 'ErrorCode': 202, 'Error': e} logging.info('LocationChange error:%s' % e) @@ -448,13 +495,16 @@ class Manufacturing_Connect(http.Controller): if 'DeviceId' in ret: logging.info('DeviceId:%s' % ret['DeviceId']) if 'IsComplete' in ret: + rfid_codes = [] + workorder_ids = [] if ret['IsComplete'] is True or ret['IsComplete'] is False: for i in range(1, 5): logging.info('F-RfidCode:%s' % i) if f'RfidCode{i}' in ret: rfid_code = ret[f'RfidCode{i}'] logging.info('RfidCode:%s' % rfid_code) - if rfid_code is not None: + if rfid_code is not None and rfid_code != '': + rfid_codes.append(rfid_code) domain = [ ('rfid_code', '=', rfid_code), ('routing_type', '=', 'CNC加工'), ('state', '!=', 'rework') @@ -462,6 +512,7 @@ class Manufacturing_Connect(http.Controller): workorder = request.env['mrp.workorder'].sudo().search(domain, order='id asc') if workorder: for order in workorder: + workorder_ids.append(order.id) if order.production_line_state == '待上产线': logging.info( '工单产线状态:%s' % order.production_line_state) @@ -470,23 +521,30 @@ class Manufacturing_Connect(http.Controller): ('processing_panel', '=', order.processing_panel)]) if panel_workorder: panel_workorder.write({'production_line_state': '已上产线'}) - workpiece_delivery = request.env['sf.workpiece.delivery'].sudo().search( - [ - ('rfid_code', '=', rfid_code), ('type', '=', '上产线'), - ('production_id', '=', order.production_id.id), - ('workorder_id', '=', order.id), - ('workorder_state', '=', 'done')]) - if workpiece_delivery.status == '待下发': - workpiece_delivery.write({'is_manual_work': True}) + # workpiece_delivery = request.env['sf.workpiece.delivery'].sudo().search( + # [ + # ('rfid_code', '=', rfid_code), ('type', '=', '上产线'), + # ('production_id', '=', order.production_id.id), + # ('workorder_id', '=', order.id), + # ('workorder_state', '=', 'done')]) + # if workpiece_delivery.status == '待下发': + # workpiece_delivery.write({'is_manual_work': True}) + # 下发 else: res = {'Succeed': False, 'ErrorCode': 204, 'Error': 'DeviceId为%s没有对应的已配送工件数据' % ret['DeviceId']} + if ret['IsComplete'] is True: + # 向AGV任务调度下发运送空料架任务 + workorders = request.env['mrp.workorder'].browse(workorder_ids) + request.env['sf.agv.scheduling'].add_scheduling(ret['DeviceId'], '运送空料架', workorders) else: res = {'Succeed': False, 'ErrorCode': 203, 'Error': '未传IsComplete字段'} else: res = {'Succeed': False, 'ErrorCode': 201, 'Error': '未传DeviceId字段'} + except RepeatTaskException as e: + logging.info('AGVToProduct error:%s' % e) except Exception as e: - res = {'Succeed': False, 'ErrorCode': 202, 'Error': e} + res = {'Succeed': False, 'ErrorCode': 202, 'Error': str(e)} logging.info('AGVToProduct error:%s' % e) return json.JSONEncoder().encode(res) @@ -509,7 +567,8 @@ class Manufacturing_Connect(http.Controller): logging.info('ret:%s' % ret) if 'DeviceId' in ret: logging.info('DeviceId:%s' % ret['DeviceId']) - delivery_Arr = [] + # delivery_Arr = [] + workorder_ids = [] if 'IsComplete' in ret: if ret['IsComplete'] is True or ret['IsComplete'] is False: for i in range(1, 5): @@ -517,7 +576,7 @@ class Manufacturing_Connect(http.Controller): if f'RfidCode{i}' in ret: rfid_code = ret[f'RfidCode{i}'] logging.info('RfidCode:%s' % rfid_code) - if rfid_code is not None: + if rfid_code is not None and rfid_code != '': domain = [ ('rfid_code', '=', rfid_code), ('routing_type', '=', 'CNC加工'), ('state', '!=', 'rework') @@ -525,6 +584,7 @@ class Manufacturing_Connect(http.Controller): workorder = request.env['mrp.workorder'].sudo().search(domain, order='id asc') if workorder: for order in workorder: + workorder_ids.append(order.id) if order.production_line_state == '已上产线': logging.info( '工单产线状态:%s' % order.production_line_state) @@ -534,35 +594,41 @@ class Manufacturing_Connect(http.Controller): if panel_workorder: panel_workorder.write({'production_line_state': '已下产线'}) workorder.write({'state': 'to be detected'}) - workpiece_delivery = request.env['sf.workpiece.delivery'].sudo().search( - [ - ('rfid_code', '=', rfid_code), ('type', '=', '下产线'), - ('production_id', '=', order.production_id.id), - ('workorder_id', '=', order.id), - ('workorder_state', '=', 'done')]) - if workpiece_delivery: - delivery_Arr.append(workpiece_delivery.id) + # workpiece_delivery = request.env['sf.workpiece.delivery'].sudo().search( + # [ + # ('rfid_code', '=', rfid_code), ('type', '=', '下产线'), + # ('production_id', '=', order.production_id.id), + # ('workorder_id', '=', order.id), + # ('workorder_state', '=', 'done')]) + # if workpiece_delivery: + # delivery_Arr.append(workpiece_delivery.id) else: res = {'Succeed': False, 'ErrorCode': 204, 'Error': 'DeviceId为%s没有对应的已配送工件数据' % ret['DeviceId']} - if delivery_Arr: - logging.info('delivery_Arr:%s' % delivery_Arr) - delivery_workpiece = request.env['sf.workpiece.delivery'].sudo().search( - [('id', 'in', delivery_Arr)]) - if delivery_workpiece: - logging.info('开始向agv下发下产线任务') - agv_site = request.env['sf.agv.site'].sudo().search([]) - if agv_site: - has_site = agv_site.update_site_state() - if has_site is True: - is_free = delivery_workpiece._check_avgsite_state() - if is_free is True: - delivery_workpiece._delivery_avg() - logging.info('agv下发下产线任务下发完成') + # if delivery_Arr: + # logging.info('delivery_Arr:%s' % delivery_Arr) + # delivery_workpiece = request.env['sf.workpiece.delivery'].sudo().search( + # [('id', 'in', delivery_Arr)]) + # if delivery_workpiece: + # logging.info('开始向agv下发下产线任务') + # agv_site = request.env['sf.agv.site'].sudo().search([]) + # if agv_site: + # has_site = agv_site.update_site_state() + # if has_site is True: + # is_free = delivery_workpiece._check_avgsite_state() + # if is_free is True: + # delivery_workpiece._delivery_avg() + # logging.info('agv下发下产线任务下发完成') + if ret['IsComplete'] is True: + # 向AGV任务调度下发下产线任务 + workorders = request.env['mrp.workorder'].browse(workorder_ids) + request.env['sf.agv.scheduling'].add_scheduling(ret['DeviceId'], '下产线', workorders) else: res = {'Succeed': False, 'ErrorCode': 203, 'Error': '未传IsComplete字段'} + except RepeatTaskException as e: + logging.info('AGVToProduct error:%s' % e) except Exception as e: - res = {'Succeed': False, 'ErrorCode': 202, 'Error': e} + res = {'Succeed': False, 'ErrorCode': 202, 'Error': str(e)} logging.info('AGVDownProduct error:%s' % e) return json.JSONEncoder().encode(res) @@ -600,3 +666,27 @@ class Manufacturing_Connect(http.Controller): res = {'Succeed': False, 'ErrorCode': 202, 'Error': e} logging.info('AGVDownProduct error:%s' % e) return json.JSONEncoder().encode(res) + + @http.route('/AutoDeviceApi/GetAgvStationState', type='json', auth='none', methods=['GET', 'POST'], csrf=False, + cors="*") + def AGVStationState(self, **kw): + """ + 中控推送接驳站状态 + :param kw: + :return: + """ + logging.info('AGVStationState:%s' % kw) + try: + res = {'Succeed': True} + datas = request.httprequest.data + ret = json.loads(datas) + request.env['center_control.interface.log'].sudo().create( + {'content': ret, 'name': 'AutoDeviceApi/AGVStationState'}) + logging.info('ret:%s' % ret) + if 'DeviceId' in ret and 'AtHome' in ret: + logging.info('DeviceId:%s, AtHome:%s' % (ret['DeviceId'], ret['AtHome'])) + request.env['sf.agv.site'].update_site_state({ret['DeviceId']: '占用' if ret['AtHome'] else '空闲'}) + except Exception as e: + res = {'Succeed': False, 'ErrorCode': 202, 'Error': str(e)} + logging.info('AGVDownProduct error:%s' % e) + return json.JSONEncoder().encode(res) \ No newline at end of file diff --git a/sf_manufacturing/controllers/workpiece.py b/sf_manufacturing/controllers/workpiece.py index 5c5d6f22..5d79a1b3 100644 --- a/sf_manufacturing/controllers/workpiece.py +++ b/sf_manufacturing/controllers/workpiece.py @@ -25,15 +25,14 @@ class Workpiece(http.Controller): if 'reqCode' in ret: if 'method' in ret: if ret['method'] == 'end': - req_codes = ret['reqCode'].split(',') - for req_code in req_codes: - workpiece_delivery = request.env['sf.workpiece.delivery'].sudo().search( - [('name', '=', req_code.strip()), ('task_completion_time', '=', False)]) - if workpiece_delivery: - workpiece_delivery.write({'status': '已配送', 'task_completion_time': datetime.now()}) - else: - res = {'Succeed': False, 'ErrorCode': 203, - 'Error': '该reqCode暂未查到对应的工件配送记录'} + # 找到对应的AGV调度任务 + agv_scheduling = request.env['sf.agv.scheduling'].sudo().search( + [('name', '=', ret['reqCode']), ('state', '=', '配送中')]) + if agv_scheduling: + agv_scheduling.finish_scheduling() + else: + res = {'Succeed': False, 'ErrorCode': 203, + 'Error': '该reqCode暂未查到对应的AGV任务记录'} else: res = {'Succeed': False, 'ErrorCode': 204, 'Error': '未传method字段'} else: diff --git a/sf_manufacturing/data/agv_scheduling_data.xml b/sf_manufacturing/data/agv_scheduling_data.xml new file mode 100644 index 00000000..f9dc6362 --- /dev/null +++ b/sf_manufacturing/data/agv_scheduling_data.xml @@ -0,0 +1,15 @@ + + + + + AGV调度 + sf.agv.scheduling + B%(year)s%(month)s%(day)s + 4 + 1 + standard + True + + + + \ No newline at end of file diff --git a/sf_manufacturing/models/__init__.py b/sf_manufacturing/models/__init__.py index 3de23ef3..b93d613e 100644 --- a/sf_manufacturing/models/__init__.py +++ b/sf_manufacturing/models/__init__.py @@ -9,3 +9,4 @@ from . import stock from . import res_user from . import production_line_base from . import agv_setting +from . import agv_scheduling diff --git a/sf_manufacturing/models/agv_scheduling.py b/sf_manufacturing/models/agv_scheduling.py new file mode 100644 index 00000000..4fbfbf8d --- /dev/null +++ b/sf_manufacturing/models/agv_scheduling.py @@ -0,0 +1,253 @@ +import requests + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +import logging + +_logger = logging.getLogger(__name__) + + +class RepeatTaskException(UserError): + pass + + +class AgvScheduling(models.Model): + _name = 'sf.agv.scheduling' + _description = 'agv调度' + _order = 'id desc' + + name = fields.Char('任务单号', index=True, copy=False) + + def _get_agv_route_type_selection(self): + return self.env['sf.agv.task.route'].fields_get(['route_type'])['route_type']['selection'] + + agv_route_type = fields.Selection(selection=_get_agv_route_type_selection, string='任务类型', required=True) + agv_route_name = fields.Char('任务路线名称') + start_site_id = fields.Many2one('sf.agv.site', '起点接驳站', required=True) + end_site_id = fields.Many2one('sf.agv.site', '终点接驳站', tracking=True) + site_state = fields.Selection([ + ('占用', '占用'), + ('空闲', '空闲')], string='终点接驳站状态', default='占用') + state = fields.Selection([ + ('待下发', '待下发'), + ('配送中', '配送中'), + ('已配送', '已配送'), + ('已取消', '已取消')], string='状态', default='待下发', tracking=True) + workorder_ids = fields.Many2many('mrp.workorder', 'sf_agv_scheduling_mrp_workorder_ref', string='关联工单') + task_create_time = fields.Datetime('任务创建时间') + task_delivery_time = fields.Datetime('任务下发时间') + task_completion_time = fields.Datetime('任务完成时间') + task_duration = fields.Char('任务时长', compute='_compute_task_duration') + + @api.depends('agv_route_type') + def _compute_delivery_workpieces(self): + for record in self: + if record.agv_route_type == '运送空料架': + record.delivery_workpieces = '/' + else: + record.delivery_workpieces = '、'.join(record.workorder_ids.mapped('production_id.name')) + + delivery_workpieces = fields.Char('配送工件', compute=_compute_delivery_workpieces) + + @api.model + def web_search_read(self, domain=None, fields=None, offset=0, limit=None, order=None, count_limit=None): + domain = domain or [] + new_domain = [] + for index, item in enumerate(domain): + if isinstance(item, list): + if item[0] == 'delivery_workpieces': + new_domain.append('&') + new_domain.append(['workorder_ids.production_id.name', item[1], item[2]]) + new_domain.append(['agv_route_type', '!=', '运送空料架']) + continue + new_domain.append(item) + + return super(AgvScheduling, self).web_search_read(new_domain, fields, limit=limit, offset=offset) + + @api.depends('task_completion_time', 'task_delivery_time') + def _compute_task_duration(self): + for rec in self: + if rec.task_completion_time and rec.task_delivery_time: + rec.task_duration = str(rec.task_completion_time - rec.task_delivery_time) + else: + rec.task_duration = '' + + @api.model_create_multi + def create(self, vals_list): + # We generate a standard reference + for vals in vals_list: + vals['name'] = self.env['ir.sequence'].next_by_code('sf.agv.scheduling') or _('New') + return super().create(vals_list) + + def add_scheduling(self, agv_start_site_name, agv_route_type, workorders): + """ add_scheduling(agv_start_site_id, agv_route_type, workorders) -> agv_scheduling + 新增AGV调度 + params: + agv_start_site_name: AGV起点接驳站名称 + agv_route_type: AGV任务类型 + workorders: 工单 + """ + _logger.info('创建AGV调度任务\r\n起点为【%s】,任务类型为【%s】,工单为【%s】' % (agv_start_site_name, agv_route_type, workorders)) + if not workorders: + raise UserError(_('工单不能为空')) + agv_start_site = self.env['sf.agv.site'].sudo().search([('name', '=', agv_start_site_name)], limit=1) + if not agv_start_site: + raise UserError(_('不存在名称为【%s】的接驳站,请先创建!' % agv_start_site_name)) + # 如果存在相同任务类型工单的AGV调度任务,则提示错误 + agv_scheduling = self.sudo().search([ + ('workorder_ids', 'in', workorders.ids), + ('agv_route_type', '=', agv_route_type), + ('state', 'in', ['待下发', '配送中']) + ], limit=1) + if agv_scheduling: + # 计算agv_scheduling.workorder_ids与workorders的交集 + repetitive_workorders = agv_scheduling.workorder_ids & workorders + raise RepeatTaskException( + '制造订单号【%s】已存在于【%s】AGV调度任务,请勿重复下发!' % + (','.join(repetitive_workorders.mapped('production_id.name')), agv_scheduling.name) + ) + + vals = { + 'start_site_id': agv_start_site.id, + 'agv_route_type': agv_route_type, + 'workorder_ids': workorders.ids, + # 'workpiece_delivery_ids': deliveries.mapped('id') if deliveries else [], + 'task_create_time': fields.Datetime.now() + } + # 如果只有唯一任务路线,则自动赋予终点接驳站跟任务名称 + agv_routes = self.env['sf.agv.task.route'].sudo().search([ + ('route_type', '=', agv_route_type), + ('start_site_id', '=', agv_start_site.id) + ]) + if not agv_routes: + raise UserError(_('不存在起点为【%s】的【%s】任务路线,请先创建!' % (agv_start_site_name, agv_route_type))) + idle_route = None + if len(agv_routes) == 1: + idle_route = agv_routes[0] + vals.update({'end_site_id': idle_route.end_site_id.id, 'agv_route_name': idle_route.name}) + else: + # 判断终点接驳站是否为空闲 + idle_routes = agv_routes.filtered(lambda r: r.end_site_id.state == '空闲') + if idle_routes: + # 将空闲的路线按照终点接驳站名称排序 + idle_routes = sorted(idle_routes, key=lambda r: r.end_site_id.name) + idle_route = idle_routes[0] + vals.update({'end_site_id': idle_route.end_site_id.id, 'agv_route_name': idle_route.name}) + try: + scheduling = self.env['sf.agv.scheduling'].sudo().create(vals) + # 触发空闲接驳站状态更新,触发新任务下发 + if idle_route and idle_route.end_site_id.state == '空闲': + scheduling.dispatch_scheduling(idle_route) + + except Exception as e: + _logger.error('添加AGV调度任务失败: %s', e) + raise UserError(_('添加AGV调度任务失败: %s', e)) + + return scheduling + + def on_site_state_change(self, agv_site_id, agv_site_state): + """ + 响应AGV接驳站站点状态变化 + params: + agv_site_id: 接驳站ID + agv_site_state: 站点状态('空闲', '占用') + """ + if agv_site_state == '空闲': + # 查询终点接驳站为agv_site_id的AGV路线 + task_routes = self.env['sf.agv.task.route'].sudo().search([('end_site_id', '=', agv_site_id)]) + agv_scheduling = self.env['sf.agv.scheduling'].sudo().search( + [('state', '=', '待下发'), ('agv_route_type', 'in', task_routes.mapped('route_type'))], + order='id asc', + limit=1 + ) + task_route = task_routes.filtered( + lambda r: r.start_site_id == agv_scheduling.start_site_id and r.start_site_id == agv_scheduling.start_site_id + ) + # 下发AGV调度任务并修改接驳站状态为占用 + agv_scheduling.dispatch_scheduling(task_route) + + def _delivery_avg(self): + config = self.env['res.config.settings'].get_values() + position_code_arr = [{ + 'positionCode': self.start_site_id.name, + 'code': '00' + }, { + 'positionCode': self.end_site_id.name, + 'code': '00' + }] + res = {'reqCode': self.name, 'reqTime': '', 'clientCode': '', 'tokenCode': '', + 'taskTyp': 'F01', 'ctnrTyp': '', 'ctnrCode': '', 'wbCode': config['wbcode'], + 'positionCodePath': position_code_arr, + 'podCode': '', + 'podDir': '', 'materialLot': '', 'priority': '', 'taskCode': '', 'agvCode': '', 'materialLot': '', + 'data': ''} + try: + logging.info('AGV请求路径:%s' % config['agv_rcs_url']) + logging.info('AGV-json:%s' % res) + headers = {'Content-Type': 'application/json'} + ret = requests.post((config['agv_rcs_url']), json=res, headers=headers) + ret = ret.json() + logging.info('config-ret:%s' % ret) + if ret['code'] == 0: + return True + else: + raise UserError(ret['message']) + except Exception as e: + logging.info('config-e:%s' % e) + raise UserError("工件配送请求agv失败:%s" % e) + + def button_cancel(self): + # 弹出二次确认窗口后执行 + for rec in self: + if rec.state != '待下发': + raise UserError('只有待下发状态的AGV调度任务才能取消!') + rec.state = '已取消' + + def finish_scheduling(self): + """ + 完成调度任务 + """ + for rec in self: + if rec.state != '配送中': + return False + _logger.info('AGV任务调度:完成任务%s' % rec) + rec.state = '已配送' + rec.task_completion_time = fields.Datetime.now() + + def dispatch_scheduling(self, agv_task_route): + """ + 下发调度任务 + params: + agv_route sf.agv.task.route对象 + """ + for rec in self: + if rec.state != '待下发': + return False + _logger.info('AGV任务调度:下发调度任务,路线为%s' % agv_task_route) + # rec._delivery_avg() + rec.state = '配送中' + rec.task_delivery_time = fields.Datetime.now() + rec.site_state = '空闲' + rec.end_site_id = agv_task_route.end_site_id.id + rec.agv_route_name = agv_task_route.name + # 更新接驳站状态 + rec.env['sf.agv.site'].update_site_state({rec.end_site_id.name: '占用'}, False) + + def write(self, vals): + if vals.get('state', False): + if vals['state'] == '已取消': + self.env['sf.workpiece.delivery'].search([('agv_scheduling_id', '=', self.id)]).write({'status': '待下发'}) + elif vals['state'] == '已配送': + self.env['sf.workpiece.delivery'].search([('agv_scheduling_id', '=', self.id)]).write({'status': '已配送'}) + return super().write(vals) + + +class ResMrpWorkOrder(models.Model): + _inherit = 'mrp.workorder' + + agv_scheduling_ids = fields.Many2many( + 'sf.agv.scheduling', + 'sf_agv_scheduling_mrp_workorder_ref', + string='AGV调度', + domain=[('state', '!=', '已取消')]) diff --git a/sf_manufacturing/models/agv_setting.py b/sf_manufacturing/models/agv_setting.py index 06f9edba..820ed539 100644 --- a/sf_manufacturing/models/agv_setting.py +++ b/sf_manufacturing/models/agv_setting.py @@ -5,50 +5,77 @@ import time from odoo import fields, models, api from odoo.exceptions import UserError +_logger = logging.getLogger(__name__) + class AgvSetting(models.Model): _name = 'sf.agv.site' _description = 'agv站点' name = fields.Char('位置编号') - owning_region = fields.Char('所属区域') + # owning_region = fields.Char('所属区域') state = fields.Selection([ ('占用', '占用'), ('空闲', '空闲')], string='状态') divide_the_work = fields.Char('主要分工') active = fields.Boolean('有效', default=True) + workcenter_id = fields.Many2one(string='所属区域', comodel_name='mrp.workcenter', tracking=True, + domain=[('is_agv_scheduling', '=', True)]) - def update_site_state(self): - # 调取中控的接驳站接口并修改对应站点的状态 - config = self.env['res.config.settings'].get_values() - # token = sf_sync_config['token'Ba F2CF5DCC-1A00-4234-9E95-65603F70CC8A] - headers = {'Authorization': config['center_control_Authorization']} - center_control_url = config['center_control_url'] + "/AutoDeviceApi/GetAgvStationState?date=" - timestamp = int(time.time()) - center_control_url += str(timestamp) - logging.info('工件配送-请求中控地址:%s' % center_control_url) - try: - center_control_r = requests.get(center_control_url, headers=headers, timeout=10) # 设置超时为60秒 - ret = center_control_r.json() - logging.info('工件配送-请求中控站点信息:%s' % ret) - self.env['center_control.interface.log'].sudo().create( - {'content': ret, 'name': 'AutoDeviceApi/GetAgvStationState?date=%s' % str(timestamp)}) - if ret['Succeed'] is True: - datas = ret['Datas'] - for item in self: - for da in datas: - if da['DeviceId'] == item.name: - if da['AtHome'] is True: - item.state = '占用' - else: - item.state = '空闲' - return True - except requests.exceptions.Timeout: - logging.error('工件配送-请求中控接口超时') - return False - except requests.exceptions.RequestException as e: - logging.error('工件配送-请求中控接口错误: %s', e) - return False + # name必须唯一 + _sql_constraints = [ + ('name_uniq', 'unique (name)', '站点编号必须唯一!'), + ] + + # def update_site_state(self): + # # 调取中控的接驳站接口并修改对应站点的状态 + # config = self.env['res.config.settings'].get_values() + # # token = sf_sync_config['token'Ba F2CF5DCC-1A00-4234-9E95-65603F70CC8A] + # headers = {'Authorization': config['center_control_Authorization']} + # center_control_url = config['center_control_url'] + "/AutoDeviceApi/GetAgvStationState?date=" + # timestamp = int(time.time()) + # center_control_url += str(timestamp) + # logging.info('工件配送-请求中控地址:%s' % center_control_url) + # try: + # center_control_r = requests.get(center_control_url, headers=headers, timeout=10) # 设置超时为60秒 + # ret = center_control_r.json() + # logging.info('工件配送-请求中控站点信息:%s' % ret) + # self.env['center_control.interface.log'].sudo().create( + # {'content': ret, 'name': 'AutoDeviceApi/GetAgvStationState?date=%s' % str(timestamp)}) + # if ret['Succeed'] is True: + # datas = ret['Datas'] + # for item in self: + # for da in datas: + # if da['DeviceId'] == item.name: + # if da['AtHome'] is True: + # item.state = '占用' + # else: + # item.state = '空闲' + # return True + # except requests.exceptions.Timeout: + # logging.error('工件配送-请求中控接口超时') + # return False + # except requests.exceptions.RequestException as e: + # logging.error('工件配送-请求中控接口错误: %s', e) + # return False + + def update_site_state(self, agv_site_state_arr, notify=True): + """ + 更新接驳站状态 + params: + agv_site_state_arr: {'A01': '空闲', 'B01': '占用'} + notify: 是否通知调度(非中控发起的状态改变不触发调度任务) + """ + if isinstance(agv_site_state_arr, dict): + for agv_site_name, is_occupy in agv_site_state_arr.items(): + agv_site = self.env['sf.agv.site'].sudo().search([('name', '=', agv_site_name)]) + if agv_site: + agv_site.state = is_occupy + if notify: + self.env['sf.agv.scheduling'].on_site_state_change(agv_site.id, agv_site.state) + else: + _logger.error("更新失败:接驳站站点错误!%s" % agv_site_name) + raise UserError("更新失败:接驳站站点错误!") class AgvTaskRoute(models.Model): @@ -71,6 +98,17 @@ class AgvTaskRoute(models.Model): if self.end_site_id == self.start_site_id: raise UserError("您选择的终点接驳站与起点接驳站重复,请重新选择") + workcenter_id = fields.Many2one(string='所属区域', comodel_name='mrp.workcenter', domain=[('is_agv_scheduling', '=', True)], + compute="_compute_region") + + @api.depends('end_site_id') + def _compute_region(self): + for record in self: + if record.end_site_id: + record.workcenter_id = record.end_site_id.workcenter_id + else: + record.workcenter_id = None + class Center_controlInterfaceLog(models.Model): _name = 'center_control.interface.log' diff --git a/sf_manufacturing/models/mrp_workcenter.py b/sf_manufacturing/models/mrp_workcenter.py index 64cf2d8d..461ef6ad 100644 --- a/sf_manufacturing/models/mrp_workcenter.py +++ b/sf_manufacturing/models/mrp_workcenter.py @@ -124,6 +124,8 @@ class ResWorkcenter(models.Model): res[wc_id] = [(datetime.fromtimestamp(s), datetime.fromtimestamp(e)) for s, e, _ in final_intervals_wc] return res + # AGV是否可配送 + is_agv_scheduling = fields.Boolean(string="AGV所属区域", tracking=True) class ResWorkcenterProductivity(models.Model): _inherit = 'mrp.workcenter.productivity' diff --git a/sf_manufacturing/models/mrp_workorder.py b/sf_manufacturing/models/mrp_workorder.py index ccdfd8d1..1b4cc642 100644 --- a/sf_manufacturing/models/mrp_workorder.py +++ b/sf_manufacturing/models/mrp_workorder.py @@ -135,6 +135,9 @@ class ResMrpWorkOrder(models.Model): surface_technics_picking_count = fields.Integer("外协出入库", compute='_compute_surface_technics_picking_ids') surface_technics_purchase_count = fields.Integer("外协采购", compute='_compute_surface_technics_purchase_ids') + # 是否绑定托盘 + is_trayed = fields.Boolean(string='是否绑定托盘', default=False) + @api.depends('name', 'production_id.name') def _compute_surface_technics_picking_ids(self): for workorder in self: @@ -371,10 +374,10 @@ class ResMrpWorkOrder(models.Model): vals['leave_id'] = leave.id self.write(vals) - @api.onchange('rfid_code') - def _onchange(self): - if self.rfid_code and self.state == 'progress': - self.workpiece_delivery_ids[0].write({'rfid_code': self.rfid_code}) + # @api.onchange('rfid_code') + # def _onchange(self): + # if self.rfid_code and self.state == 'progress': + # self.workpiece_delivery_ids[0].write({'rfid_code': self.rfid_code}) def get_plan_workorder(self, production_line): tomorrow = (date.today() + timedelta(days=+1)).strftime("%Y-%m-%d") @@ -687,25 +690,35 @@ class ResMrpWorkOrder(models.Model): k, item), 'cmm_ids': False if route.routing_type != 'CNC加工' else self.env['sf.cmm.program']._json_cmm_program(k, item), - 'workpiece_delivery_ids': False if not route.routing_type == '装夹预调' else self._json_workpiece_delivery_list( - production) + # 'workpiece_delivery_ids': False if not route.routing_type == '装夹预调' else self._json_workpiece_delivery_list( + # production) }] return workorders_values_str - def _json_workpiece_delivery_list(self, production): - up_route = self.env['sf.agv.task.route'].search([('route_type', '=', '上产线')], limit=1, order='id asc') - down_route = self.env['sf.agv.task.route'].search([('route_type', '=', '下产线')], limit=1, order='id asc') + def _json_workpiece_delivery_list(self): + # 修改在装夹工单完成后,生成上产线的工件配送单 + + # up_route = self.env['sf.agv.task.route'].search([('route_type', '=', '上产线')], limit=1, order='id asc') + # down_route = self.env['sf.agv.task.route'].search([('route_type', '=', '下产线')], limit=1, order='id asc') return [ [0, '', - {'production_id': production.id, 'production_line_id': production.production_line_id.id, 'type': '上产线', - 'route_id': up_route.id, - 'feeder_station_start_id': up_route.start_site_id.id, - 'feeder_station_destination_id': up_route.end_site_id.id}], - [0, '', - {'production_id': production.id, 'production_line_id': production.production_line_id.id, 'type': '下产线', - 'route_id': down_route.id, - 'feeder_station_start_id': down_route.start_site_id.id, - 'feeder_station_destination_id': down_route.end_site_id.id}]] + { + 'production_id': self.production_id.id, + 'production_line_id': self.production_id.production_line_id.id, + 'type': '上产线', + 'is_cnc_program_down': True, + 'rfid_code': self.rfid_code + # 'route_id': up_route.id, + # 'feeder_station_start_id': agv_start_site_id, + # 'feeder_station_destination_id': up_route.end_site_id.id + } + ], + # [0, '', + # {'production_id': production.id, 'production_line_id': production.production_line_id.id, 'type': '下产线', + # 'route_id': down_route.id, + # 'feeder_station_start_id': down_route.start_site_id.id, + # 'feeder_station_destination_id': down_route.end_site_id.id}] + ] # 拼接工单对象属性值(表面工艺) def _json_workorder_surface_process_str(self, production, route, process_parameter, supplier_id): @@ -1149,6 +1162,8 @@ class ResMrpWorkOrder(models.Model): record.process_state = '待加工' # record.write({'process_state': '待加工'}) record.production_id.process_state = '待加工' + # 生成工件配送单 + record.workpiece_delivery_ids = record._json_workpiece_delivery_list() if record.routing_type == 'CNC加工': record.process_state = '待解除装夹' # record.write({'process_state': '待加工'}) @@ -1236,6 +1251,19 @@ class ResMrpWorkOrder(models.Model): record.production_id.button_mark_done1() # record.production_id.state = 'done' + # 解绑托盘 + def unbind_tray(self): + self.write({ + 'rfid_code': False, + 'tray_serial_number': False, + 'tray_product_id': False, + 'tray_brand_id': False, + 'tray_type_id': False, + 'tray_model_id': False, + 'is_trayed': False + }) + + # 将FTP的检测报告文件下载到临时目录 def download_reportfile_tmp(self, workorder, reportpath): logging.info('reportpath/ftp地址:%s' % reportpath) @@ -1275,6 +1303,66 @@ class ResMrpWorkOrder(models.Model): else: raise UserError("无关联制造订单或关联序列号,无法打印。请检查!") + @api.model + def get_views(self, views, options=None): + res = super().get_views(views, options) + if res['views'].get('list', {}) and self.env.context.get('search_default_workcenter_id'): + workcenter = self.env['mrp.workcenter'].browse(self.env.context.get('search_default_workcenter_id')) + tree_view = res['views']['list'] + if workcenter.name == '工件拆卸中心': + arch = etree.fromstring(tree_view['arch']) + # 查找 tree 标签 + tree_element = arch.xpath("//tree")[0] + + # 查找或创建 header 标签 + header_element = tree_element.find('header') + if header_element is None: + header_element = etree.Element('header') + tree_element.insert(0, header_element) + + # 创建并添加按钮元素 + button_element = etree.Element('button', { + 'name': 'button_delivery', + 'type': 'object', + 'string': '解除装夹', + 'class': 'btn-primary', + # 'className': 'btn-primary', + 'modifiers': '{"force_show": 1}' + }) + header_element.append(button_element) + + # 更新 tree_view 的 arch + tree_view['arch'] = etree.tostring(arch, encoding='unicode') + return res + + def button_delivery(self): + production_ids = [] + workorder_ids = [] + delivery_type = '运送空料架' + max_num = 4 # 最大配送数量 + if len(self) > max_num: + raise UserError('仅限于拆卸1-4个制造订单,请重新选择') + for item in self: + if item.state != 'ready': + raise UserError('请选择状态为【就绪】的工单进行解除装夹') + + production_ids.append(item.production_id.id) + workorder_ids.append(item.id) + return { + 'name': _('确认'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'sf.workpiece.delivery.wizard', + 'target': 'new', + 'context': { + # 'default_delivery_ids': [(6, 0, delivery_ids)], + 'default_production_ids': [(6, 0, production_ids)], + 'default_delivery_type': delivery_type, + 'default_workorder_ids': [(6, 0, workorder_ids)], + 'default_workcenter_id': self.env.context.get('default_workcenter_id'), + 'default_confirm_button': '确认解除' + }} + class CNCprocessing(models.Model): _name = 'sf.cnc.processing' @@ -1483,6 +1571,7 @@ class SfWorkOrderBarcodes(models.Model): raise UserError('该Rfid【%s】绑定的是【%s】, 不是托盘!!!' % (barcode, lot.product_id.name)) self.process_state = '待检测' self.date_start = datetime.now() + self.is_trayed = True else: raise UserError('没有找到Rfid为【%s】的托盘信息!!!' % barcode) # stock_move_line = self.env['stock.move.line'].search([('lot_name', '=', barcode)]) @@ -1554,20 +1643,25 @@ class WorkPieceDelivery(models.Model): feeder_station_destination_id = fields.Many2one('sf.agv.site', '目的接驳站') task_delivery_time = fields.Datetime('任务下发时间') task_completion_time = fields.Datetime('任务完成时间') - type = fields.Selection( - [('上产线', '上产线'), ('下产线', '下产线'), ('运送空料架', '运送空料架')], string='类型') + + def _get_agv_route_type_selection(self): + return self.env['sf.agv.task.route'].fields_get(['route_type'])['route_type']['selection'] + + type = fields.Selection(selection=_get_agv_route_type_selection, string='类型') delivery_duration = fields.Float('配送时长', compute='_compute_delivery_duration') status = fields.Selection( - [('待下发', '待下发'), ('待配送', '待配送'), ('已配送', '已配送'), ('已取消', '已取消')], string='状态', - default='待下发', - tracking=True) + [('待下发', '待下发'), ('已下发', '已下发'), ('已配送', '已配送'), ('已取消', '已取消')], string='状态', + default='待下发', tracking=True) is_cnc_program_down = fields.Boolean('程序是否下发', default=False, tracking=True) is_manual_work = fields.Boolean('人工操作', default=False) active = fields.Boolean(string="有效", default=True) + agv_scheduling_id = fields.Many2one('sf.agv.scheduling', 'AGV任务调度') + + @api.model def create(self, vals): - if vals['route_id'] and vals.get('type') is None: + if vals.get('route_id') and vals.get('type') is None: vals['type'] = '运送空料架' else: if vals.get('name', '/') == '/' or vals.get('name', '/') is False: @@ -1615,84 +1709,45 @@ class WorkPieceDelivery(models.Model): def button_delivery(self): delivery_ids = [] production_ids = [] + workorder_ids = [] is_cnc_down = 0 is_not_production_line = 0 - is_not_route = 0 same_production_line_id = None - same_route_id = None - down_status = '待下发' - production_type = None - num = 0 + delivery_type = '上产线' + max_num = 4 # 最大配送数量 + if len(self) > max_num: + raise UserError('仅限于配送1-4个制造订单,请重新选择') for item in self: - num += 1 - if production_type is None: - production_type = item.type - if item.type == "运送空料架": - if num >= 2: - raise UserError('仅选择一条路线进行配送,请重新选择') - else: - delivery_ids.append(item.id) - else: - if num > 4: - raise UserError('仅限于配送1-4个制造订单,请重新选择') - if item.status in ['待配送', '已配送']: - raise UserError('请选择状态为【待下发】的制造订单进行配送') - if item.route_id: - if same_route_id is None: - same_route_id = item.route_id.id - if item.route_id.id != same_route_id: - is_not_route += 1 - # else: - # raise UserError('请选择【任务路线】再进行配送') - # if item.production_id.production_line_state == '已下产线' and item.state == '待下发' and item.type == '下产线': - # raise UserError('该制造订单已下产线,无需配送') - if production_type != item.type: - raise UserError('请选择类型为%s的制造订单进行配送' % production_type) - if down_status != item.status: - up_workpiece = self.search([('type', '=', '上产线'), ('production_id', '=', item.production_id), - ('status', '=', '待下发')]) - if up_workpiece: - raise UserError('您所选择的制造订单暂未上产线,请在上产线后再进行配送') - else: - raise UserError('请选择状态为【待下发】的制造订单进行配送') - - if same_production_line_id is None: - same_production_line_id = item.production_line_id.id - if item.production_line_id.id != same_production_line_id: - is_not_production_line += 1 - if item.is_cnc_program_down is False: - is_cnc_down += 1 - if is_cnc_down == 0 and is_not_production_line == 0 and is_not_route == 0: - delivery_ids.append(item.id) - production_ids.append(item.production_id.id) + if item.status != '待下发': + raise UserError('请选择状态为【待下发】的制造订单进行配送') + if same_production_line_id is None: + same_production_line_id = item.production_line_id.id + if item.production_line_id.id != same_production_line_id: + is_not_production_line += 1 + if item.is_cnc_program_down is False: + is_cnc_down += 1 + if is_cnc_down == 0 and is_not_production_line == 0: + delivery_ids.append(item.id) + production_ids.append(item.production_id.id) + workorder_ids.append(item.workorder_id.id) if is_cnc_down >= 1: raise UserError('您所选择制造订单的【CNC程序】暂未下发,请在程序下发后再进行配送') if is_not_production_line >= 1: raise UserError('您所选择制造订单的【目的生产线】不一致,请重新确认') - if is_not_route >= 1: - raise UserError('您所选择制造订单的【任务路线】不一致,请重新确认') - is_free = self._check_avgsite_state() - if is_free is True: - if delivery_ids: - return { - 'name': _('确认'), - 'type': 'ir.actions.act_window', - 'view_mode': 'form', - 'res_model': 'sf.workpiece.delivery.wizard', - 'target': 'new', - 'context': { - 'default_delivery_ids': [(6, 0, delivery_ids)], - 'default_production_ids': [(6, 0, production_ids)], - 'default_destination_production_line_id': same_production_line_id, - 'default_route_id': same_route_id, - 'default_type': production_type, - }} - else: - if production_type == '运送空料架': - raise UserError("您所选择的【任务路线】的【终点接驳站】已占用,请在该接驳站空闲时进行配送") - else: - raise UserError( - "您所选择制造订单的【任务路线】的【终点接驳站】已占用,请在该接驳站空闲时或选择其他路线进行配送") + return { + 'name': _('确认'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'sf.workpiece.delivery.wizard', + 'target': 'new', + 'context': { + 'default_delivery_ids': [(6, 0, delivery_ids)], + 'default_production_ids': [(6, 0, production_ids)], + 'default_delivery_type': delivery_type, + 'default_workorder_ids': [(6, 0, workorder_ids)], + 'default_confirm_button': '确认配送' + }} + # 验证agv站点是否可用 def _check_avgsite_state(self): @@ -1798,6 +1853,7 @@ class WorkPieceDelivery(models.Model): obj.delivery_duration = 0.0 + class CMMprogram(models.Model): _name = 'sf.cmm.program' _description = "CMM程序" diff --git a/sf_manufacturing/security/ir.model.access.csv b/sf_manufacturing/security/ir.model.access.csv index 9b5b5e28..fd3b0d21 100644 --- a/sf_manufacturing/security/ir.model.access.csv +++ b/sf_manufacturing/security/ir.model.access.csv @@ -150,5 +150,12 @@ access_sf_processing_panel_group_sf_order_user,sf_processing_panel_group_sf_orde access_sf_production_wizard_group_sf_order_user,sf_production_wizard_group_sf_order_user,model_sf_production_wizard,sf_base.group_sf_order_user,1,1,1,0 access_sf_processing_panel_group_plan_dispatch,sf_processing_panel_group_plan_dispatch,model_sf_processing_panel,sf_base.group_plan_dispatch,1,1,1,0 +access_sf_agv_scheduling_admin,sf_agv_scheduling_admin,model_sf_agv_scheduling,base.group_system,1,1,1,1 +access_sf_agv_scheduling_group_sf_order_user,sf_agv_scheduling_group_sf_order_user,model_sf_agv_scheduling,sf_base.group_sf_order_user,1,1,1,0 +access_sf_agv_scheduling_group_sf_mrp_manager,sf_agv_scheduling_group_sf_mrp_manager,model_sf_agv_scheduling,sf_base.group_sf_mrp_manager,1,1,1,0 +access_sf_agv_scheduling_group_sf_equipment_user,sf_agv_scheduling_group_sf_equipment_user,model_sf_agv_scheduling,sf_base.group_sf_equipment_user,1,1,1,0 + + + diff --git a/sf_manufacturing/static/src/js/customRFID.js b/sf_manufacturing/static/src/js/customRFID.js index 8f775350..eced2e70 100644 --- a/sf_manufacturing/static/src/js/customRFID.js +++ b/sf_manufacturing/static/src/js/customRFID.js @@ -1,15 +1,17 @@ var RFID = '' $(document).off('keydown') -console.log(2222) -$(document).on('keydown', '.modal.d-block.o_technical_modal,body.o_web_client', function (e) { - const dom = $('.customRFID') - if(!dom.length) return +$(document).on('keydown', 'body.o_web_client', function (e) { setTimeout(() => { RFID = '' }, 200) + if(e.key == 'Enter' && e.keyCode == 13 || e.key == 'Tab' && e.keyCode == 9){ + console.log(RFID) if(!RFID || RFID.length <= 3) return; - dom.children('span').text(RFID) + $('[name="button_start"]').trigger('click') + setTimeout(() => { + $('.o_dialog .modal-footer .btn-primary').trigger('click') + }, 50) RFID = '' return; } diff --git a/sf_manufacturing/static/src/js/workpiece_delivery_wizard_confirm.js b/sf_manufacturing/static/src/js/workpiece_delivery_wizard_confirm.js new file mode 100644 index 00000000..cb044955 --- /dev/null +++ b/sf_manufacturing/static/src/js/workpiece_delivery_wizard_confirm.js @@ -0,0 +1,53 @@ +odoo.define('sf_manufacturing.action_dispatch_confirm', function (require) { + const core = require('web.core'); + const ajax = require('web.ajax'); + const Dialog = require('web.Dialog'); + var rpc = require('web.rpc'); + var _t = core._t; + + async function dispatch_confirm(parent, {params}) { + console.log(params, 'params') + console.log("
本次下发的工件数量为:" + params.workorder_count + ",是否确认?
", 'content') + const dialog = new Dialog(parent, { + title: "确认", + $content: $('
').append("请确认是否仅配送" + params.workorder_count + "个工件?"), + buttons: [ + { text: "确认", classes: 'btn-primary', close: true, click: () => dispatchConfirmed(parent, params) }, + { text: "取消", close: true }, + ], + }); + dialog.open(); + + + async function dispatchConfirmed(parent, params) { + console.log(parent, 'parent') + rpc.query({ + model: 'sf.workpiece.delivery.wizard', + method: 'confirm', + args: [params.active_id] + , + kwargs: { + context: params.context, + } + }).then(res => { + console.log(res, 'res') + console.log(res.name, 'res') + parent.services.action.doAction({ + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'target': 'new', + 'params': { + 'message': '任务下发成功!AGV任务调度编号为【' + res.name + '】', + 'type': 'success', + 'sticky': false, + 'next': {'type': 'ir.actions.act_window_close'}, + } + }); + }) + + } + } + + core.action_registry.add('dispatch_confirm', dispatch_confirm); + return dispatch_confirm; +}); diff --git a/sf_manufacturing/static/src/xml/button_show_on_tree.xml b/sf_manufacturing/static/src/xml/button_show_on_tree.xml new file mode 100644 index 00000000..00533841 --- /dev/null +++ b/sf_manufacturing/static/src/xml/button_show_on_tree.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sf_manufacturing/views/agv_scheduling_views.xml b/sf_manufacturing/views/agv_scheduling_views.xml new file mode 100644 index 00000000..ef932e54 --- /dev/null +++ b/sf_manufacturing/views/agv_scheduling_views.xml @@ -0,0 +1,79 @@ + + + + + + agv调度 + sf.agv.scheduling + + + + + + + + + + + + + + + + + + {'readonly': [('state', 'in', ['purchase'])]} diff --git a/sf_sale/views/sale_order_view.xml b/sf_sale/views/sale_order_view.xml index 00cca206..ce27a4df 100644 --- a/sf_sale/views/sale_order_view.xml +++ b/sf_sale/views/sale_order_view.xml @@ -6,6 +6,13 @@ sale.order + + sf_base.group_sale_salemanager,sf_base.group_sale_director + + + mrp.group_mrp_user,sf_base.group_sale_salemanager,sf_base.group_sale_director + + @@ -35,12 +42,12 @@
+ + options="{'no_create': True}" + attrs="{'readonly': ['|',('state', '=', '已拆解'),('id', '!=', False)]}"/> @@ -833,10 +839,26 @@ - + + + + + + + + +