提交 b6cf9cb8 authored 作者: 伍姿英's avatar 伍姿英

Merge branch 'release/3.8.1'

...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
<!-- 清理向导生成的临时附件--> <!-- 清理向导生成的临时附件-->
<record id="cron_cleanup_temp_attachments" model="ir.cron"> <record id="cron_cleanup_temp_attachments" model="ir.cron">
<field name="name">清理向导临时附件</field> <field name="name">清理向导临时附件</field>
<field name="model_id" ref="model_batch_get_pod_info_wizard"/> <field name="model_id" ref="model_common_common"/>
<field name="state">code</field> <field name="state">code</field>
<field name="code">model.cron_cleanup_temp_attachments()</field> <field name="code">model.cron_cleanup_temp_attachments()</field>
<field name='interval_number'>1</field> <field name='interval_number'>1</field>
...@@ -39,4 +39,4 @@ ...@@ -39,4 +39,4 @@
</record> </record>
</data> </data>
</odoo> </odoo>
\ No newline at end of file
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import base64
import datetime import datetime
import gc
import json
import logging import logging
import os
import tempfile
import time
import pytz import pytz
from odoo import models import requests
from odoo import models, _
from odoo.exceptions import ValidationError
from .redis_connection import redis_connection from .redis_connection import redis_connection
...@@ -16,7 +24,6 @@ class CommonCommon(models.Model): ...@@ -16,7 +24,6 @@ class CommonCommon(models.Model):
_name = 'common.common' _name = 'common.common'
_description = u'公用基础类' _description = u'公用基础类'
def process_num(self, input_str): def process_num(self, input_str):
""" """
处理导入 处理导入
...@@ -69,7 +76,7 @@ class CommonCommon(models.Model): ...@@ -69,7 +76,7 @@ class CommonCommon(models.Model):
timezone_offset = self.env['common.common'].sudo( timezone_offset = self.env['common.common'].sudo(
).get_time_zone(user_tz) ).get_time_zone(user_tz)
local_time = local_time + \ local_time = local_time + \
datetime.timedelta(hours=int(timezone_offset)) datetime.timedelta(hours=int(timezone_offset))
return local_time.strftime('%Y-%m-%d %H:%M:%S'), timezone_offset return local_time.strftime('%Y-%m-%d %H:%M:%S'), timezone_offset
except Exception as e: except Exception as e:
# 如果出现任何错误,返回UTC时间 # 如果出现任何错误,返回UTC时间
...@@ -92,7 +99,7 @@ class CommonCommon(models.Model): ...@@ -92,7 +99,7 @@ class CommonCommon(models.Model):
timezone_offset = self.env['common.common'].sudo( timezone_offset = self.env['common.common'].sudo(
).get_time_zone(user_tz) ).get_time_zone(user_tz)
local_time = local_time + \ local_time = local_time + \
datetime.timedelta(hours=int(timezone_offset)) datetime.timedelta(hours=int(timezone_offset))
local_tz = pytz.timezone(user_tz) local_tz = pytz.timezone(user_tz)
# 确保时间是本地时区 # 确保时间是本地时区
if local_time.tzinfo is None: if local_time.tzinfo is None:
...@@ -168,6 +175,446 @@ class CommonCommon(models.Model): ...@@ -168,6 +175,446 @@ class CommonCommon(models.Model):
""" """
return r return r
def get_pod_pdf_files(self, bill_numbers, api_sub_path):
"""
获取POD PDF文件
:param bill_numbers: 订单号列表
:param api_sub_path: API子路径
:return: PDF文件列表
"""
api_url = self.env['ir.config_parameter'].sudo().get_param(
'last_mile_pod_api_url',
'http://172.104.52.150:7002'
)
if not api_url:
raise ValidationError(_('API URL not configured'))
request_data = {
"bill_numbers": bill_numbers
}
try:
response = requests.post(
f"{api_url}{api_sub_path}",
headers={'Content-Type': 'application/json', 'Accept': 'application/json'},
json=request_data
)
if response.status_code == 200:
result = response.json()
if not result:
raise ValidationError(_('API returned empty response'))
if not result.get('success'):
error_msg = result.get('message', 'Unknown error')
raise ValidationError(_('API returned error: %s') % error_msg)
results = result.get('results', [])
if not results:
raise ValidationError(_('No PDF files found in API response'))
pdf_file_arr = []
for result_item in results:
if result_item.get('success'):
bill_number = result_item.get('bill_number')
filename = result_item.get('filename')
base64_data = result_item.get('base64')
pdf_file_arr.append({
'bl_no': bill_number,
'file_name': filename,
'file_data': base64_data
})
return pdf_file_arr
else:
raise ValidationError(_('Failed to get PDF file from API: %s') % response.text)
except requests.exceptions.RequestException as e:
raise ValidationError(_('API request failed: %s') % str(e))
def push_sync_pod_task(self, processed_files, file_type, pod_desc, filter_temu=False):
"""
推送POD同步任务到redis
:param processed_files: 处理后的文件数组
:param file_type: 清关文件类型名称
:param pod_desc: 用于提示的信息描述,例如“尾程POD”或“货站提货POD”
:param filter_temu: 是否过滤掉bl_type为temu的提单
"""
redis_conn = self.get_redis()
if not redis_conn or redis_conn == 'no':
raise ValidationError('未连接redis,无法同步%s,请联系管理员' % pod_desc)
bl_ids = []
for file_info in processed_files:
bl = file_info.get('bl')
if not bl:
continue
if filter_temu and getattr(bl, 'bl_type', False) == 'temu':
continue
clearance_file = file_info.get('clearance_file')
if not clearance_file:
continue
bl_ids.append(bl.id)
if not bl_ids:
return
payload = {
'ids': bl_ids,
'action_type': 'sync_last_mile_pod',
'user_login': self.env.user.login,
'file_type': file_type
}
try:
redis_conn.lpush('mail_push_package_list', json.dumps(payload))
except Exception as e:
logging.error('sync_last_mile_pod redis error:%s' % e)
raise ValidationError('推送%s同步任务到redis失败,请重试或联系管理员' % pod_desc)
def cleanup_temp_attachments(self, bl_objs):
"""
清理与当前向导相关的临时附件,包括服务器和本地开发环境的物理文件
"""
try:
if not bl_objs:
return
attachments = self.env['ir.attachment'].search([
('res_model', '=', bl_objs._name),
('res_id', 'in', bl_objs.ids),
('name', 'like', 'temp_pod_%')
])
if attachments:
attachments.unlink()
except Exception as e:
_logger.error('清理临时附件失败: %s' % str(e))
def cron_cleanup_temp_attachments(self):
"""
定时清理向导生成的临时附件
每天早上8点执行,删除1天之前创建的temp_pod_开头的附件
"""
try:
today = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
cutoff = today - datetime.timedelta(days=1)
_logger.info('开始执行定时清理临时附件任务,清理时间点: %s' % cutoff.strftime('%Y-%m-%d %H:%M:%S'))
sql_query = """
SELECT id, name, res_model, res_id, create_date, store_fname
FROM ir_attachment
WHERE name LIKE 'temp_pod_%%'
AND create_date < '%s'
ORDER BY create_date DESC
""" % (cutoff.strftime('%Y-%m-%d %H:%M:%S'))
self.env.cr.execute(sql_query)
sql_results = self.env.cr.fetchall()
if sql_results:
attachment_ids = [result[0] for result in sql_results]
temp_attachments = self.env['ir.attachment'].sudo().browse(attachment_ids)
attachment_count = len(temp_attachments)
_logger.info(
'找到 %s 个%s之前创建的临时附件,开始清理' % (attachment_count, cutoff.strftime('%Y-%m-%d')))
import os
from odoo.tools import config
data_dir = config.filestore(self.env.cr.dbname)
for attachment in temp_attachments:
try:
if hasattr(attachment, 'store_fname') and attachment.store_fname:
file_path = attachment.store_fname
elif hasattr(attachment, 'datas_fname') and attachment.datas_fname:
file_path = attachment.datas_fname
else:
file_path = attachment.name
if data_dir and file_path:
full_path = os.path.join(data_dir, file_path)
if os.path.exists(full_path):
os.remove(full_path)
except Exception as file_e:
_logger.warning('删除物理文件失败 %s: %s' % (attachment.name, str(file_e)))
temp_attachments.unlink()
except Exception as e:
_logger.error('定时清理临时附件失败: %s' % str(e))
def merge_pod_pdfs(self, processed_files):
"""
合并处理后的POD PDF文件
:param processed_files: 处理后的文件列表,每个元素为字典包含bl_no, file_data, bl
:return: 合并后的PDF文件数据和文件名
"""
import fitz
temp_file_path = None
try:
valid_files = []
for file_info in processed_files:
if file_info.get('bl_no') and file_info.get('file_data') and file_info.get('bl'):
valid_files.append(file_info)
if not valid_files:
_logger.warning("没有有效的PDF文件可以合并")
return None, None
if len(valid_files) == 1:
file_info = valid_files[0]
bl = file_info['bl']
bl_no = bl.bl_no
file_data = file_info['file_data']
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
pdf_filename = f"POD文件_{bl_no}_{timestamp}.pdf"
_logger.info("单个PDF文件直接使用: %s", pdf_filename)
return file_data, pdf_filename
_logger.info("开始合并 %s 个PDF文件", len(valid_files))
temp_file_path = tempfile.mktemp(suffix='.pdf')
merged_pdf = fitz.open()
bl_numbers = []
for file_info in valid_files:
bl = file_info['bl']
bl_no = bl.bl_no
file_data = file_info['file_data']
bl_numbers.append(bl_no)
source_pdf = None
try:
pdf_binary = base64.b64decode(file_data)
source_pdf = fitz.open(stream=pdf_binary, filetype="pdf")
merged_pdf.insert_pdf(source_pdf)
_logger.info("已添加提单 %s 的PDF到合并文档(%s 页)", bl_no, len(source_pdf))
except Exception as e:
_logger.error("合并提单 %s 的PDF失败: %s", bl_no, str(e))
continue
finally:
if source_pdf:
source_pdf.close()
gc.collect()
if len(merged_pdf) > 0:
merged_pdf.save(temp_file_path, garbage=4, deflate=True, clean=True)
merged_pdf.close()
with open(temp_file_path, 'rb') as f:
pdf_data = f.read()
merged_pdf_base64 = base64.b64encode(pdf_data).decode('utf-8')
del pdf_data
gc.collect()
bl_numbers_str = '_'.join(bl_numbers[:5])
if len(bl_numbers) > 5:
bl_numbers_str += f'_等{len(bl_numbers)}个'
timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
pdf_filename = f"合并POD文件_{bl_numbers_str}_{timestamp}.pdf"
_logger.info("成功合并 %s 个PDF文件,文件名: %s", len(bl_numbers), pdf_filename)
return merged_pdf_base64, pdf_filename
_logger.warning("没有有效的PDF文件可以合并")
return None, None
except Exception as e:
_logger.error("合并PDF文件失败: %s", str(e))
return None, None
finally:
if temp_file_path and os.path.exists(temp_file_path):
try:
os.remove(temp_file_path)
_logger.info("已删除临时文件: %s", temp_file_path)
except Exception as e:
_logger.warning("删除临时文件失败: %s", str(e))
def match_pod_files(self, pdf_file_arr, bl_obj, include_processing_failed=False):
"""
匹配POD文件与提单
:param pdf_file_arr: PDF文件数组,每个元素为字典包含file_name, file_data, bl_no
:param bl_obj: 提单记录集
:param include_processing_failed: 是否包含处理失败的文件
:return: 匹配后的文件数组,每个元素为字典包含bl, file_name, file_data, bl_no
"""
processed_files = []
for bl in bl_obj:
select_bl_no = self.process_match_str(bl.bl_no)
if not select_bl_no:
continue
for pdf_file in pdf_file_arr:
file_name = pdf_file.get('file_name')
file_data = pdf_file.get('file_data')
raw_bl_no = pdf_file.get('bl_no')
bl_no = self.process_match_str(raw_bl_no)
if bl_no and select_bl_no == bl_no:
file_info = {
'bl': bl,
'file_name': file_name,
'file_data': file_data,
'bl_no': raw_bl_no or bl.bl_no,
}
if include_processing_failed:
file_info['processing_failed'] = False
processed_files.append(file_info)
break
return processed_files
def write_pod_pdf_files(self, processed_files, fix_name):
"""
Write PDF file to clearance files # 回写PDF文件到清关文件
:param processed_files: 处理后的文件数组
:param fix_name:
"""
clearance_model = self.env['cc.clearance.file']
valid_entries = []
bl_ids = set()
for file_info in processed_files:
bl = file_info.get('bl')
if not bl:
_logger.warning("跳过没有提单信息的文件")
continue
file_name = file_info.get('file_name', '')
file_data = file_info.get('file_data', '')
if not file_data:
continue
valid_entries.append((file_info, bl, file_name, file_data))
bl_ids.add(bl.id)
if not valid_entries:
return
existing_clearance = clearance_model.search(
[('bl_id', 'in', list(bl_ids)), ('file_name', '=', fix_name), ('file', '=', False)]
)
existing_by_bl = {rec.bl_id.id: rec for rec in existing_clearance}
create_vals_list = []
create_infos = []
for file_info, bl, file_name, file_data in valid_entries:
clearance_file = existing_by_bl.get(bl.id)
if clearance_file:
clearance_file.write({
'attachment_name': file_name,
'file': file_data
})
file_info['clearance_file'] = clearance_file
self.env.cr.commit()
else:
create_vals_list.append({
'bl_id': bl.id,
'file_name': fix_name,
'attachment_name': file_name,
'file': file_data
})
create_infos.append(file_info)
if create_vals_list:
new_records = clearance_model.create(create_vals_list)
_logger.info("已创建新清关文件记录: %s", new_records.ids)
self.env.cr.commit()
for clearance_file, file_info in zip(new_records, create_infos):
bl = file_info['bl']
file_info['clearance_file'] = clearance_file
def serialize_pod_processed_files(self, processed_files):
"""
将processed_files序列化为JSON字符串,文件数据存储到临时附件中
:param processed_files: 处理后的文件数组
:return: JSON字符串(只包含引用信息,不包含文件数据)
注意:不在这里清理临时附件,因为预览时需要保留附件数据,
只有在确认操作完成后才清理临时附件
"""
serialized_data = []
for file_info in processed_files:
if not file_info.get('bl'):
continue
bl = file_info['bl']
file_data = file_info.get('file_data', '')
file_name = file_info.get('file_name', f"{bl.bl_no}.pdf")
attachment_id = None
if file_data:
try:
attachment = self.env['ir.attachment'].create({
'name': f"temp_pod_{bl.bl_no}_{int(time.time())}.pdf",
'datas': file_data,
'type': 'binary',
'res_model': bl._name,
'res_id': bl.id,
})
attachment_id = attachment.id
_logger.info("已创建临时附件存储文件: %s, ID: %s", attachment.name, attachment_id)
except Exception as e:
_logger.error("创建临时附件失败: %s", str(e))
else:
_logger.warning("提单 %s 的文件数据为空,无法创建附件", bl.bl_no)
data = {
'bl_id': bl.id,
'bl_no': bl.bl_no,
'file_name': file_name,
'attachment_id': attachment_id,
}
if 'ocr_texts' in file_info:
data['ocr_texts'] = file_info['ocr_texts']
if 'valid_packages' in file_info and file_info['valid_packages']:
valid_packages = file_info['valid_packages']
if hasattr(valid_packages, 'ids'):
data['valid_package_ids'] = valid_packages.ids
elif isinstance(valid_packages, list):
data['valid_package_ids'] = [p.id for p in valid_packages if hasattr(p, 'id')]
else:
data['valid_package_ids'] = []
_logger.info(
"序列化时保存valid_packages: 提单 %s, 满足条件的小包ID: %s",
bl.bl_no, data['valid_package_ids']
)
serialized_data.append(data)
return json.dumps(serialized_data, ensure_ascii=False)
def deserialize_pod_processed_files(self, json_data):
"""
将JSON字符串反序列化为processed_files(从附件中读取文件数据)
:param json_data: JSON字符串
:return: 处理后的文件数组
"""
if not json_data:
return []
try:
serialized_data = json.loads(json_data)
processed_files = []
for data in serialized_data:
bl_id = data.get('bl_id')
attachment_id = data.get('attachment_id')
if not bl_id:
continue
bl = self.env['cc.bl'].browse(bl_id)
if not bl.exists():
continue
file_data = ''
if attachment_id:
try:
attachment = self.env['ir.attachment'].browse(attachment_id)
if attachment.exists():
file_data = attachment.datas
_logger.info(
"从附件读取文件: %s, ID: %s, 数据长度: %s",
attachment.name, attachment_id,
len(file_data) if file_data else 0
)
else:
_logger.warning("附件不存在: %s", attachment_id)
except Exception as e:
_logger.error("读取附件失败: %s", str(e))
else:
_logger.warning("提单 %s 没有附件ID,无法读取文件数据", bl.bl_no)
file_info = {
'bl': bl,
'bl_no': data.get('bl_no', ''),
'file_name': data.get('file_name', ''),
'file_data': file_data,
}
if 'ocr_texts' in data:
file_info['ocr_texts'] = data['ocr_texts']
if 'valid_package_ids' in data and data['valid_package_ids']:
valid_package_ids = data['valid_package_ids']
valid_packages = self.env['cc.ship.package'].browse(valid_package_ids)
file_info['valid_packages'] = valid_packages
_logger.info(
"反序列化时恢复valid_packages: 提单 %s, 满足条件的小包ID: %s, 数量: %s",
bl.bl_no, valid_package_ids, len(valid_packages)
)
processed_files.append(file_info)
return processed_files
except Exception as e:
_logger.error("反序列化processed_files失败: %s", str(e))
return []
def init_timezone_data(self, name): def init_timezone_data(self, name):
timezone_data = {} timezone_data = {}
timezone_data['Africa/Abidjan'] = 0 timezone_data['Africa/Abidjan'] = 0
......
...@@ -426,9 +426,9 @@ ...@@ -426,9 +426,9 @@
</field> </field>
</record> </record>
<!-- 获取尾程快递POD --> <!-- 下载货站提货POD-->
<record id="bl_get_delivery_pod_info_server_action" model="ir.actions.server"> <record id="bl_download_pod_server_action" model="ir.actions.server">
<field name="name">Batch Get Last Mile POD Info</field> <field name="name">Batch Download PickUp POD</field>
<field name="model_id" ref="model_cc_bl"/> <field name="model_id" ref="model_cc_bl"/>
<field name="binding_model_id" ref="model_cc_bl"/> <field name="binding_model_id" ref="model_cc_bl"/>
<field name="state">code</field> <field name="state">code</field>
...@@ -436,13 +436,13 @@ ...@@ -436,13 +436,13 @@
<field name="groups_id" eval="[(4, ref('ccs_base.group_clearance_of_customs_user'))]"/> <field name="groups_id" eval="[(4, ref('ccs_base.group_clearance_of_customs_user'))]"/>
<field name="code"> <field name="code">
if records: if records:
action = records.action_batch_get_last_mile_pod_info() action = records.action_batch_download_pod('货站提货POD')
</field> </field>
</record> </record>
<!-- 下载货站提货POD--> <!-- 获取尾程快递POD -->
<record id="bl_download_pod_server_action" model="ir.actions.server"> <record id="bl_get_delivery_pod_info_server_action" model="ir.actions.server">
<field name="name">Batch Download PickUp POD</field> <field name="name">Batch Get Last Mile POD Info</field>
<field name="model_id" ref="model_cc_bl"/> <field name="model_id" ref="model_cc_bl"/>
<field name="binding_model_id" ref="model_cc_bl"/> <field name="binding_model_id" ref="model_cc_bl"/>
<field name="state">code</field> <field name="state">code</field>
...@@ -450,7 +450,7 @@ ...@@ -450,7 +450,7 @@
<field name="groups_id" eval="[(4, ref('ccs_base.group_clearance_of_customs_user'))]"/> <field name="groups_id" eval="[(4, ref('ccs_base.group_clearance_of_customs_user'))]"/>
<field name="code"> <field name="code">
if records: if records:
action = records.action_batch_download_pod('货站提货POD') action = records.action_batch_get_last_mile_pod_info()
</field> </field>
</record> </record>
......
...@@ -60,10 +60,16 @@ class BatchGetLastMilePodInfoWizard(models.TransientModel): ...@@ -60,10 +60,16 @@ class BatchGetLastMilePodInfoWizard(models.TransientModel):
def _get_bill_numbers(self, bl_objs): def _get_bill_numbers(self, bl_objs):
_logger.info(f"开始预览操作,提单数量: {len(bl_objs)}") _logger.info(f"开始预览操作,提单数量: {len(bl_objs)}")
start_time = time.time()
logging.info(f"开始预览操作,开始时间{len(bl_objs)}")
# 调用接口获取提单pdf文件 # 调用接口获取提单pdf文件
pdf_file_arr = self._get_pdf_file_arr(bl_objs) pdf_file_arr = self._get_pdf_file_arr(bl_objs)
end_time = time.time()
logging.info(f"获取PDF文件耗时: {end_time - start_time} 秒")
# 处理PDF文件,匹配提单对象 # 处理PDF文件,匹配提单对象
processed_files = self._match_bl_by_file_name(pdf_file_arr, bl_objs) processed_files = self._match_bl_by_file_name(pdf_file_arr, bl_objs)
file_end_time = time.time()
logging.info(f"处理PDF文件耗时: {file_end_time - end_time} 秒")
# 把没有匹配到文件的进行提示 # 把没有匹配到文件的进行提示
error_bl = [] error_bl = []
matched_bl_ids = [f['bl'].id for f in processed_files if f.get('bl')] matched_bl_ids = [f['bl'].id for f in processed_files if f.get('bl')]
...@@ -192,57 +198,8 @@ class BatchGetLastMilePodInfoWizard(models.TransientModel): ...@@ -192,57 +198,8 @@ class BatchGetLastMilePodInfoWizard(models.TransientModel):
从API获取PDF文件 从API获取PDF文件
""" """
bill_numbers = [self.env['common.common'].sudo().process_match_str(bl.bl_no) for bl in bl_objs] bill_numbers = [self.env['common.common'].sudo().process_match_str(bl.bl_no) for bl in bl_objs]
# 调用API获取PDF文件 common = self.env['common.common'].sudo()
api_url = self.env['ir.config_parameter'].sudo().get_param('last_mile_pod_api_url', return common.get_pod_pdf_files(bill_numbers, '/api/pod/pdfs')
'http://172.104.52.150:7002')
if not api_url:
raise ValidationError(_('API URL not configured'))
# 构建请求数据
request_data = {
"bill_numbers": bill_numbers
}
try:
response = requests.post(
f"{api_url}/api/pod/pdfs",
headers={'Content-Type': 'application/json', 'Accept': 'application/json'},
json=request_data,
timeout=30
)
if response.status_code == 200:
result = response.json()
# 检查API响应结构
if not result:
raise ValidationError(_('API returned empty response'))
if not result.get('success'):
error_msg = result.get('message', 'Unknown error')
raise ValidationError(_('API returned error: %s') % error_msg)
# 处理结果数据
results = result.get('results', [])
if not results:
raise ValidationError(_('No PDF files found in API response'))
# 构建PDF文件数组
pdf_file_arr = []
for result_item in results:
if result_item.get('success'):
# 验证必要字段
bill_number = result_item.get('bill_number')
filename = result_item.get('filename')
base64_data = result_item.get('base64')
pdf_file_arr.append({
'bl_no': bill_number,
'file_name': filename,
'file_data': base64_data
})
return pdf_file_arr
else:
raise ValidationError(_('Failed to get PDF file from API: %s') % response.text)
except requests.exceptions.RequestException as e:
raise ValidationError(_('API request failed: %s') % str(e))
def _write_pdf_file(self, processed_files, fix_name='尾程交接POD(待大包数量和箱号)'): def _write_pdf_file(self, processed_files, fix_name='尾程交接POD(待大包数量和箱号)'):
""" """
...@@ -250,182 +207,17 @@ class BatchGetLastMilePodInfoWizard(models.TransientModel): ...@@ -250,182 +207,17 @@ class BatchGetLastMilePodInfoWizard(models.TransientModel):
:param processed_files: 处理后的文件数组 :param processed_files: 处理后的文件数组
:param fix_name: :param fix_name:
""" """
for file_info in processed_files: common = self.env['common.common'].sudo()
if not file_info.get('bl'): common.write_pod_pdf_files(processed_files, fix_name)
_logger.warning("跳过没有提单信息的文件")
continue
bl = file_info['bl']
file_name = file_info.get('file_name', '')
file_data = file_info.get('file_data', '')
if not file_data:
continue
# 如果有文件为空的就回写,否则就创建新的清关文件记录
clearance_file = self.env['cc.clearance.file'].search(
[('bl_id', '=', bl.id), ('file_name', '=', fix_name), ('file', '=', False)], limit=1)
if clearance_file:
clearance_file.write({
'attachment_name': file_name,
'file': file_data
})
_logger.info(f"更新清关文件记录: 提单 {bl.bl_no}")
else:
# 创建新的清关文件记录
clearance_file = self.env['cc.clearance.file'].create({
'bl_id': bl.id,
'file_name': fix_name,
'attachment_name': file_name,
'file': file_data
})
_logger.info(f"创建新的清关文件记录: 提单 {bl.bl_no}")
file_info['clearance_file'] = clearance_file
def _merge_pdf_files(self, processed_files): def _merge_pdf_files(self, processed_files):
""" common = self.env['common.common'].sudo()
合并所有涂抹后的PDF文件为一个PDF并保存到pdf_file字段 pdf_data, pdf_filename = common.merge_pod_pdfs(processed_files)
使用临时文件方式减少内存占用 if pdf_data and pdf_filename:
:param processed_files: 处理后的文件数组 self.write({
""" 'pdf_file': pdf_data,
import fitz # PyMuPDF 'pdf_filename': pdf_filename
from datetime import datetime })
import tempfile
import os
import gc
temp_file_path = None
try:
# 过滤有效的PDF文件
valid_files = []
for file_info in processed_files:
if file_info.get('bl_no') and file_info.get('file_data'):
valid_files.append(file_info)
if not valid_files:
_logger.warning("没有有效的PDF文件可以合并")
return
# 如果只有一个PDF文件,直接使用,不需要合并
if len(valid_files) == 1:
file_info = valid_files[0]
bl = file_info['bl']
bl_no = bl.bl_no
file_data = file_info['file_data']
file_name = file_info.get('file_name', f"{bl_no}.pdf")
# 生成文件名(包含提单号和日期)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
pdf_filename = f"POD文件_{bl_no}_{timestamp}.pdf"
# 直接保存到字段
self.write({
'pdf_file': file_data,
'pdf_filename': pdf_filename
})
_logger.info(f"单个PDF文件直接保存: {pdf_filename}")
return
# 多个PDF文件需要合并
_logger.info(f"开始合并 {len(valid_files)} 个PDF文件")
# 使用临时文件方式合并,避免内存占用过大
temp_file_path = tempfile.mktemp(suffix='.pdf')
merged_pdf = fitz.open()
bl_numbers = []
# 遍历所有处理后的PDF文件,分批处理以减少内存占用
batch_size = 5 # 每批处理5个PDF
for batch_start in range(0, len(valid_files), batch_size):
batch_files = valid_files[batch_start:batch_start + batch_size]
_logger.info(f"处理第 {batch_start // batch_size + 1} 批,共 {len(batch_files)} 个PDF")
for file_info in batch_files:
bl = file_info['bl']
bl_no = bl.bl_no
file_data = file_info['file_data']
bl_numbers.append(bl_no)
source_pdf = None
try:
# 将base64数据转换为二进制
pdf_binary = base64.b64decode(file_data)
# 打开PDF文档
source_pdf = fitz.open(stream=pdf_binary, filetype="pdf")
# 将源PDF的所有页面插入到合并的PDF中
merged_pdf.insert_pdf(source_pdf)
_logger.info(f"已添加提单 {bl_no} 的PDF到合并文档({len(source_pdf)} 页)")
except Exception as e:
_logger.error(f"合并提单 {bl_no} 的PDF失败: {str(e)}")
continue
finally:
# 立即释放资源
if source_pdf:
source_pdf.close()
gc.collect() # 强制垃圾回收
# 每批处理完后,保存到临时文件并释放内存
if batch_start + batch_size < len(valid_files):
# 保存当前合并结果到临时文件
merged_pdf.save(temp_file_path, garbage=4, deflate=True, clean=True)
merged_pdf.close()
# 重新打开临时文件继续合并
merged_pdf = fitz.open(temp_file_path)
gc.collect()
# 如果有页面,保存合并后的PDF
if len(merged_pdf) > 0:
# 使用临时文件保存,减少内存占用
if not temp_file_path:
temp_file_path = tempfile.mktemp(suffix='.pdf')
merged_pdf.save(temp_file_path, garbage=4, deflate=True, clean=True)
merged_pdf.close()
# 从临时文件读取并转换为base64
with open(temp_file_path, 'rb') as f:
pdf_data = f.read()
# 转换为base64
merged_pdf_base64 = base64.b64encode(pdf_data).decode('utf-8')
# 清理临时数据
del pdf_data
gc.collect()
# 生成文件名(包含提单号和日期)
bl_numbers_str = '_'.join(bl_numbers[:5]) # 最多显示5个提单号
if len(bl_numbers) > 5:
bl_numbers_str += f'_等{len(bl_numbers)}个'
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
pdf_filename = f"合并POD文件_{bl_numbers_str}_{timestamp}.pdf"
# 保存到字段
self.write({
'pdf_file': merged_pdf_base64,
'pdf_filename': pdf_filename
})
# 清理base64数据
del merged_pdf_base64
gc.collect()
_logger.info(f"成功合并 {len(bl_numbers)} 个PDF文件,文件名: {pdf_filename}")
else:
_logger.warning("没有有效的PDF文件可以合并")
except Exception as e:
_logger.error(f"合并PDF文件失败: {str(e)}")
finally:
# 清理临时文件
if temp_file_path and os.path.exists(temp_file_path):
try:
os.remove(temp_file_path)
_logger.info(f"已删除临时文件: {temp_file_path}")
except Exception as e:
_logger.warning(f"删除临时文件失败: {str(e)}")
def _match_bl_by_file_name(self, pdf_file_arr, bl_obj): def _match_bl_by_file_name(self, pdf_file_arr, bl_obj):
""" """
...@@ -433,331 +225,33 @@ class BatchGetLastMilePodInfoWizard(models.TransientModel): ...@@ -433,331 +225,33 @@ class BatchGetLastMilePodInfoWizard(models.TransientModel):
:param pdf_file_arr: PDF文件数组 [{'bill_number':'', 'filename':'', 'file_data':''}] :param pdf_file_arr: PDF文件数组 [{'bill_number':'', 'filename':'', 'file_data':''}]
:return: 处理后的数组 [{'bl': bl_obj, 'file_name': 'xxx.pdf', 'file_data': 'xxx', 'matched': True/False}] :return: 处理后的数组 [{'bl': bl_obj, 'file_name': 'xxx.pdf', 'file_data': 'xxx', 'matched': True/False}]
""" """
processed_files = [] common = self.env['common.common'].sudo()
for bl in bl_obj: return common.match_pod_files(pdf_file_arr, bl_obj, include_processing_failed=True)
select_bl_no = self.env['common.common'].sudo().process_match_str(bl.bl_no)
for pdf_file in pdf_file_arr:
file_name = pdf_file.get('file_name') # 获取文件名
file_data = pdf_file.get('file_data') # 获取文件数据
bl_no = self.env['common.common'].sudo().process_match_str(pdf_file.get('bl_no')) # 获取提单号
if bl_no and select_bl_no == bl_no:
# 构建处理后的文件信息
processed_file = {
'bl': bl,
'file_name': file_name,
'file_data': file_data,
'bl_no': bl.bl_no,
'processing_failed': False,
}
processed_files.append(processed_file)
break
return processed_files
def _sync_last_mile_pod(self, processed_files): def _sync_last_mile_pod(self, processed_files):
""" """
Sync last mile POD information Sync last mile POD information
:param processed_files: 处理后的文件数组 :param processed_files: 处理后的文件数组
""" """
is_fail = [] # 同步失败 common = self.env['common.common'].sudo()
for file_info in processed_files: common.push_sync_pod_task(
if not file_info['bl']: processed_files=processed_files,
continue file_type='尾程交接POD(待大包数量和箱号)',
bl = file_info['bl'] pod_desc='尾程POD',
if bl.bl_type == 'temu': filter_temu=True
continue )
# 查找清关文件并执行同步
clearance_file = file_info.get('clearance_file')
if clearance_file:
try:
clearance_file.action_sync() # 同步尾程交接POD
except Exception as e:
logging.info('_sync_last_mile_pod:%s' % e)
is_fail = True
break
_logger.info(f"Successfully synced POD for BL {bl.bl_no}")
if is_fail:
raise ValidationError('本次同步失败,请重试!')
def get_date_sync_match_node(self, processed_files):
"""
Sync matched node based on POD file, extract time from red boxes # 根据POD文件同步匹配节点
:param processed_files: 处理后的文件数组
"""
ship_packages, pod_node_id = self.get_detail_info(processed_files)
self._sync_match_node(ship_packages, pod_node_id)
def get_detail_info(self, processed_files):
"""
获取提单对应的节点以及时间
:param processed_files: 处理后的文件数组(应该已经包含valid_packages字段,只包含满足条件的小包)
:return: 提单对应的节点以及节点操作时间
"""
ship_packages = []
# 查找对应的清关节点(勾选了POD节点匹配的节点)
pod_node = self.env['cc.node'].search([
('is_pod_node', '=', True),
('node_type', '=', 'package')
], limit=1)
for file_info in processed_files:
if not file_info.get('bl'):
continue
bl = file_info['bl']
if not pod_node:
continue
# 只使用满足条件的小包(经过验证的valid_packages)
valid_packages = file_info.get('valid_packages', [])
if not valid_packages:
_logger.warning(f"提单 {bl.bl_no} 没有满足条件的小包,跳过节点推送")
continue
# 从valid_packages中提取小包ID(记录集对象或列表)
if hasattr(valid_packages, 'ids'):
# 如果是记录集对象,直接获取IDs
valid_package_ids = valid_packages.ids
elif isinstance(valid_packages, list):
# 如果是列表,提取每个对象的ID
valid_package_ids = [p.id for p in valid_packages if hasattr(p, 'id')]
else:
_logger.warning(f"提单 {bl.bl_no} valid_packages格式不正确: {type(valid_packages)}")
valid_package_ids = []
_logger.info(f"提单 {bl.bl_no} 满足条件的小包ID: {valid_package_ids} (共 {len(valid_package_ids)} 个)")
if not valid_package_ids:
_logger.warning(f"提单 {bl.bl_no} 满足条件的小包ID为空,跳过节点推送")
continue
# 从PDF文件提取红色框的时间
file_data = file_info.get('file_data')
if not file_data:
logging.info(f"提单 {bl.bl_no} 没有文件数据")
continue
ship_packages.append({
'bl_id': bl.id,
'id': valid_package_ids, # 只包含满足条件的小包ID
'tally_time': str(file_info.get('tally_time'))
})
return ship_packages, pod_node.id
def _sync_match_node(self, ship_packages, pod_node_id):
"""
同步匹配节点
:param ship_packages: 提单对应的小包以及节点信息
:param pod_node_id: 尾程POD节点匹配的节点ID
"""
# 若该提单里已有对应的小包已有节点推送日志,则不再重新推送;
_logger.info(f"同步匹配节点,提单: {ship_packages}, 节点: {pod_node_id}")
if ship_packages:
bl_objs = self.env['cc.bl'].sudo().search(
[('id', 'in', [ship_package.get('bl_id') for ship_package in ship_packages])])
redis_conn = self.env['common.common'].sudo().get_redis()
if redis_conn and redis_conn != 'no' and pod_node_id:
redis_conn.lpush('mail_push_package_list', json.dumps(
{'ids': bl_objs.ids, 'ship_packages': str(ship_packages), 'action_type': 'push_match_node',
'user_login': self.env.user.login,
'pod_node_id': pod_node_id}))
def _cleanup_temp_attachments(self, bl_objs=None): def _cleanup_temp_attachments(self, bl_objs=None):
""" common = self.env['common.common'].sudo()
清理与当前向导相关的临时附件,包括服务器和本地开发环境的物理文件 common.cleanup_temp_attachments(bl_objs)
"""
try:
attachments = self.env['ir.attachment'].search([
('res_model', '=', bl_objs[0]._name),
('res_id', 'in', bl_objs.ids),
('name', 'like', 'temp_pod_%')
])
if attachments:
# 删除数据库记录
attachments.unlink()
except Exception as e:
_logger.error(f"清理临时附件失败: {str(e)}")
def _serialize_processed_files(self, processed_files): def _serialize_processed_files(self, processed_files):
""" common = self.env['common.common'].sudo()
将processed_files序列化为JSON字符串,文件数据存储到临时附件中 return common.serialize_pod_processed_files(processed_files)
:param processed_files: 处理后的文件数组
:return: JSON字符串(只包含引用信息,不包含文件数据)
"""
# 注意:不在这里清理临时附件,因为预览时需要保留附件数据
# 只有在确认操作完成后才清理临时附件
serialized_data = []
for file_info in processed_files:
if not file_info.get('bl'):
continue
bl = file_info['bl']
file_data = file_info.get('file_data', '')
file_name = file_info.get('file_name', f"{bl.bl_no}.pdf")
# 将文件数据存储到临时附件中
attachment_id = None
if file_data:
try:
attachment = self.env['ir.attachment'].create({
'name': f"temp_pod_{bl.bl_no}_{int(time.time())}.pdf",
'datas': file_data,
'type': 'binary',
'res_model': bl._name,
'res_id': bl.id,
})
attachment_id = attachment.id
_logger.info(f"已创建临时附件存储文件: {attachment.name}, ID: {attachment_id}")
except Exception as e:
_logger.error(f"创建临时附件失败: {str(e)}")
else:
_logger.warning(f"提单 {bl.bl_no} 的文件数据为空,无法创建附件")
data = {
'bl_id': bl.id,
'bl_no': bl.bl_no,
'file_name': file_name,
'attachment_id': attachment_id, # 存储附件ID而不是文件数据
}
# OCR文本数据量小,可以直接存储
if 'ocr_texts' in file_info:
data['ocr_texts'] = file_info['ocr_texts']
# 保存valid_packages的ID列表(记录集对象无法直接序列化)
if 'valid_packages' in file_info and file_info['valid_packages']:
valid_packages = file_info['valid_packages']
# 如果是记录集对象,提取ID列表
if hasattr(valid_packages, 'ids'):
data['valid_package_ids'] = valid_packages.ids
elif isinstance(valid_packages, list):
# 如果是列表,提取每个对象的ID
data['valid_package_ids'] = [p.id for p in valid_packages if hasattr(p, 'id')]
else:
data['valid_package_ids'] = []
_logger.info(
f"序列化时保存valid_packages: 提单 {bl.bl_no}, 满足条件的小包ID: {data['valid_package_ids']}")
serialized_data.append(data)
return json.dumps(serialized_data, ensure_ascii=False)
def _deserialize_processed_files(self, json_data): def _deserialize_processed_files(self, json_data):
""" common = self.env['common.common'].sudo()
将JSON字符串反序列化为processed_files(从附件中读取文件数据) return common.deserialize_pod_processed_files(json_data)
:param json_data: JSON字符串
:return: 处理后的文件数组
"""
if not json_data:
return []
try:
serialized_data = json.loads(json_data)
processed_files = []
for data in serialized_data:
bl_id = data.get('bl_id')
attachment_id = data.get('attachment_id')
if bl_id:
bl = self.env['cc.bl'].browse(bl_id)
if bl.exists():
# 从附件中读取文件数据
file_data = ''
if attachment_id:
try:
attachment = self.env['ir.attachment'].browse(attachment_id)
if attachment.exists():
# attachment.datas 已经是 base64 编码的字符串
file_data = attachment.datas
_logger.info(
f"从附件读取文件: {attachment.name}, ID: {attachment_id}, 数据长度: {len(file_data) if file_data else 0}")
else:
_logger.warning(f"附件不存在: {attachment_id}")
except Exception as e:
_logger.error(f"读取附件失败: {str(e)}")
else:
_logger.warning(f"提单 {bl.bl_no} 没有附件ID,无法读取文件数据")
file_info = {
'bl': bl,
'bl_no': data.get('bl_no', ''),
'file_name': data.get('file_name', ''),
'file_data': file_data,
}
# 如果有OCR文本,也恢复
if 'ocr_texts' in data:
file_info['ocr_texts'] = data['ocr_texts']
# 恢复valid_packages(从ID列表重建记录集对象)
if 'valid_package_ids' in data and data['valid_package_ids']:
valid_package_ids = data['valid_package_ids']
# 重建记录集对象
valid_packages = self.env['cc.ship.package'].browse(valid_package_ids)
file_info['valid_packages'] = valid_packages
_logger.info(
f"反序列化时恢复valid_packages: 提单 {bl.bl_no}, 满足条件的小包ID: {valid_package_ids}, 数量: {len(valid_packages)}")
processed_files.append(file_info)
return processed_files
except Exception as e:
_logger.error(f"反序列化processed_files失败: {str(e)}")
return []
@api.model
def cron_cleanup_temp_attachments(self):
"""
定时清理向导生成的临时附件
每天早上8点执行,删除1天之前创建的temp_pod_开头的附件
"""
try:
# 计算1天前的时间(前一天23:59:59)
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
one_day_ago = today + timedelta(days=2) - timedelta(seconds=1) # 前一天23:59:59
_logger.info(f"开始执行定时清理临时附件任务,清理时间点: {one_day_ago.strftime('%Y-%m-%d %H:%M:%S')}")
# 构建SQL查询
sql_query = """
SELECT id, name, res_model, res_id, create_date, store_fname
FROM ir_attachment
WHERE res_model = 'batch.get.pod.info.wizard'
AND create_date < '%s'
ORDER BY create_date DESC
""" % (one_day_ago.strftime('%Y-%m-%d %H:%M:%S'))
# 执行SQL查询
self.env.cr.execute(sql_query)
sql_results = self.env.cr.fetchall()
# 将SQL结果转换为Odoo记录集
if sql_results:
attachment_ids = [result[0] for result in sql_results]
temp_attachments = self.env['ir.attachment'].sudo().browse(attachment_ids)
attachment_count = len(temp_attachments)
_logger.info(f"找到 {attachment_count} 个{one_day_ago.strftime('%Y-%m-%d')}之前创建的临时附件,开始清理")
# 删除物理文件
for attachment in temp_attachments:
try:
# 获取附件的物理文件路径
if hasattr(attachment, 'store_fname') and attachment.store_fname:
# Odoo 12+ 使用 store_fname
file_path = attachment.store_fname
elif hasattr(attachment, 'datas_fname') and attachment.datas_fname:
# 旧版本使用 datas_fname
file_path = attachment.datas_fname
else:
# 尝试从 name 字段构建路径
file_path = attachment.name
# 构建完整的文件路径
import os
from odoo.tools import config
# 获取 Odoo 数据目录
data_dir = config.filestore(self.env.cr.dbname)
if data_dir and file_path:
full_path = os.path.join(data_dir, file_path)
if os.path.exists(full_path):
os.remove(full_path)
except Exception as file_e:
_logger.warning(f"删除物理文件失败 {attachment.name}: {str(file_e)}")
# 删除数据库记录
temp_attachments.unlink()
except Exception as e:
_logger.error(f"定时清理临时附件失败: {str(e)}")
@api.depends() @api.depends()
def _compute_show_sync_last_mile_pod(self): def _compute_show_sync_last_mile_pod(self):
......
...@@ -6,9 +6,6 @@ import io ...@@ -6,9 +6,6 @@ import io
import json import json
import logging import logging
import time import time
from datetime import datetime, timedelta
import requests
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
...@@ -103,6 +100,11 @@ class BatchGetPodInfoWizard(models.TransientModel): ...@@ -103,6 +100,11 @@ class BatchGetPodInfoWizard(models.TransientModel):
processed_files_data = fields.Text(string='已处理的文件数据', help='存储已处理的文件信息(JSON格式)') processed_files_data = fields.Text(string='已处理的文件数据', help='存储已处理的文件信息(JSON格式)')
def _get_bill_numbers(self, bl_objs): def _get_bill_numbers(self, bl_objs):
"""
获取提单号
:param bl_objs: 提单记录集
:return: 提单号列表
"""
_logger.info(f"开始预览操作,提单数量: {len(bl_objs)}") _logger.info(f"开始预览操作,提单数量: {len(bl_objs)}")
# 调用接口获取提单pdf文件 # 调用接口获取提单pdf文件
pdf_file_arr = self._get_pdf_file_arr(bl_objs) pdf_file_arr = self._get_pdf_file_arr(bl_objs)
...@@ -349,7 +351,6 @@ class BatchGetPodInfoWizard(models.TransientModel): ...@@ -349,7 +351,6 @@ class BatchGetPodInfoWizard(models.TransientModel):
if action_type == '获取货站提货POD信息': if action_type == '获取货站提货POD信息':
if self.sync_last_mile_pod and successful_processed_files: if self.sync_last_mile_pod and successful_processed_files:
self._sync_last_mile_pod(successful_processed_files) self._sync_last_mile_pod(successful_processed_files)
# 同步推送匹配节点 # 同步推送匹配节点
if self.sync_match_node and successful_processed_files: if self.sync_match_node and successful_processed_files:
# 且需先对比小包当前节点的操作时间是否小于提取时间(同时区对比)若大于则不能推送, # 且需先对比小包当前节点的操作时间是否小于提取时间(同时区对比)若大于则不能推送,
...@@ -445,72 +446,8 @@ class BatchGetPodInfoWizard(models.TransientModel): ...@@ -445,72 +446,8 @@ class BatchGetPodInfoWizard(models.TransientModel):
从API获取PDF文件 从API获取PDF文件
""" """
bill_numbers = [self.env['common.common'].sudo().process_match_str(bl.bl_no) for bl in bl_objs] bill_numbers = [self.env['common.common'].sudo().process_match_str(bl.bl_no) for bl in bl_objs]
# 调用API获取PDF文件 common = self.env['common.common'].sudo()
api_url = self.env['ir.config_parameter'].sudo().get_param('last_mile_pod_api_url', return common.get_pod_pdf_files(bill_numbers, '/api/release-notes/pdfs')
'http://172.104.52.150:7002')
if not api_url:
raise ValidationError(_('API URL not configured'))
# 构建请求数据
request_data = {
"bill_numbers": bill_numbers
}
try:
response = requests.post(
f"{api_url}/api/release-notes/pdfs",
headers={'Content-Type': 'application/json'},
json=request_data
)
if response.status_code == 200:
result = response.json()
# 检查API响应结构
if not result:
raise ValidationError(_('API returned empty response'))
if not result.get('success'):
error_msg = result.get('message', 'Unknown error')
raise ValidationError(_('API returned error: %s') % error_msg)
# 处理结果数据
results = result.get('results', [])
if not results:
raise ValidationError(_('No PDF files found in API response')) # 提示:API调用成功,但没有PDF文件
# 构建PDF文件数组
pdf_file_arr = []
for result_item in results:
if result_item.get('success'):
# 验证必要字段
bill_number = result_item.get('bill_number')
filename = result_item.get('filename')
base64_data = result_item.get('base64')
if not all([bill_number, filename, base64_data]):
_logger.warning(f"跳过无效的PDF文件项: {result_item}")
continue
# 验证PDF文件
try:
pdf_binary = base64.b64decode(base64_data)
# 验证PDF文件头
if not pdf_binary.startswith(b'%PDF-'):
_logger.warning(f"API返回的文件不是有效的PDF格式,提单号: {bill_number}")
continue
pdf_file_arr.append({
'bl_no': bill_number,
'file_name': filename,
'file_data': base64_data
})
except Exception as e:
_logger.warning(f"API PDF文件验证失败,提单号: {bill_number}, 错误: {str(e)}")
continue
return pdf_file_arr
else:
raise ValidationError(_('Failed to get PDF file from API: %s') % response.text)
except requests.exceptions.RequestException as e:
raise ValidationError(_('API request failed: %s') % str(e))
def _write_pdf_file(self, processed_files, fix_name='货站提货POD'): def _write_pdf_file(self, processed_files, fix_name='货站提货POD'):
""" """
...@@ -518,182 +455,21 @@ class BatchGetPodInfoWizard(models.TransientModel): ...@@ -518,182 +455,21 @@ class BatchGetPodInfoWizard(models.TransientModel):
:param processed_files: 处理后的文件数组 :param processed_files: 处理后的文件数组
:param fix_name: :param fix_name:
""" """
for file_info in processed_files: common = self.env['common.common'].sudo()
if not file_info.get('bl'): common.write_pod_pdf_files(processed_files, fix_name)
_logger.warning("跳过没有提单信息的文件")
continue
bl = file_info['bl']
file_name = file_info.get('file_name', '')
file_data = file_info.get('file_data', '')
if not file_data:
continue
# 如果有文件为空的就回写,否则就创建新的清关文件记录
clearance_file = self.env['cc.clearance.file'].search(
[('bl_id', '=', bl.id), ('file_name', '=', fix_name), ('file', '=', False)], limit=1)
if clearance_file:
clearance_file.write({
'attachment_name': file_name,
'file': file_data
})
_logger.info(f"更新清关文件记录: 提单 {bl.bl_no}")
else:
# 创建新的清关文件记录
clearance_file = self.env['cc.clearance.file'].create({
'bl_id': bl.id,
'file_name': fix_name,
'attachment_name': file_name,
'file': file_data
})
_logger.info(f"创建新的清关文件记录: 提单 {bl.bl_no}")
file_info['clearance_file'] = clearance_file
def _merge_pdf_files(self, processed_files): def _merge_pdf_files(self, processed_files):
""" """
合并所有涂抹后的PDF文件为一个PDF并保存到pdf_file字段 合并处理后的POD PDF文件
使用临时文件方式减少内存占用
:param processed_files: 处理后的文件数组 :param processed_files: 处理后的文件数组
""" """
import fitz # PyMuPDF common = self.env['common.common'].sudo()
from datetime import datetime pdf_data, pdf_filename = common.merge_pod_pdfs(processed_files)
import tempfile if pdf_data and pdf_filename:
import os self.write({
import gc 'pdf_file': pdf_data,
'pdf_filename': pdf_filename
temp_file_path = None })
try:
# 过滤有效的PDF文件
valid_files = []
for file_info in processed_files:
if file_info.get('bl_no') and file_info.get('file_data'):
valid_files.append(file_info)
if not valid_files:
_logger.warning("没有有效的PDF文件可以合并")
return
# 如果只有一个PDF文件,直接使用,不需要合并
if len(valid_files) == 1:
file_info = valid_files[0]
bl = file_info['bl']
bl_no = bl.bl_no
file_data = file_info['file_data']
file_name = file_info.get('file_name', f"{bl_no}.pdf")
# 生成文件名(包含提单号和日期)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
pdf_filename = f"POD文件_{bl_no}_{timestamp}.pdf"
# 直接保存到字段
self.write({
'pdf_file': file_data,
'pdf_filename': pdf_filename
})
_logger.info(f"单个PDF文件直接保存: {pdf_filename}")
return
# 多个PDF文件需要合并
_logger.info(f"开始合并 {len(valid_files)} 个PDF文件")
# 使用临时文件方式合并,避免内存占用过大
temp_file_path = tempfile.mktemp(suffix='.pdf')
merged_pdf = fitz.open()
bl_numbers = []
# 遍历所有处理后的PDF文件,分批处理以减少内存占用
batch_size = 5 # 每批处理5个PDF
for batch_start in range(0, len(valid_files), batch_size):
batch_files = valid_files[batch_start:batch_start + batch_size]
_logger.info(f"处理第 {batch_start // batch_size + 1} 批,共 {len(batch_files)} 个PDF")
for file_info in batch_files:
bl = file_info['bl']
bl_no = bl.bl_no
file_data = file_info['file_data']
bl_numbers.append(bl_no)
source_pdf = None
try:
# 将base64数据转换为二进制
pdf_binary = base64.b64decode(file_data)
# 打开PDF文档
source_pdf = fitz.open(stream=pdf_binary, filetype="pdf")
# 将源PDF的所有页面插入到合并的PDF中
merged_pdf.insert_pdf(source_pdf)
_logger.info(f"已添加提单 {bl_no} 的PDF到合并文档({len(source_pdf)} 页)")
except Exception as e:
_logger.error(f"合并提单 {bl_no} 的PDF失败: {str(e)}")
continue
finally:
# 立即释放资源
if source_pdf:
source_pdf.close()
gc.collect() # 强制垃圾回收
# 每批处理完后,保存到临时文件并释放内存
if batch_start + batch_size < len(valid_files):
# 保存当前合并结果到临时文件
merged_pdf.save(temp_file_path, garbage=4, deflate=True, clean=True)
merged_pdf.close()
# 重新打开临时文件继续合并
merged_pdf = fitz.open(temp_file_path)
gc.collect()
# 如果有页面,保存合并后的PDF
if len(merged_pdf) > 0:
# 使用临时文件保存,减少内存占用
if not temp_file_path:
temp_file_path = tempfile.mktemp(suffix='.pdf')
merged_pdf.save(temp_file_path, garbage=4, deflate=True, clean=True)
merged_pdf.close()
# 从临时文件读取并转换为base64
with open(temp_file_path, 'rb') as f:
pdf_data = f.read()
# 转换为base64
merged_pdf_base64 = base64.b64encode(pdf_data).decode('utf-8')
# 清理临时数据
del pdf_data
gc.collect()
# 生成文件名(包含提单号和日期)
bl_numbers_str = '_'.join(bl_numbers[:5]) # 最多显示5个提单号
if len(bl_numbers) > 5:
bl_numbers_str += f'_等{len(bl_numbers)}个'
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
pdf_filename = f"合并POD文件_{bl_numbers_str}_{timestamp}.pdf"
# 保存到字段
self.write({
'pdf_file': merged_pdf_base64,
'pdf_filename': pdf_filename
})
# 清理base64数据
del merged_pdf_base64
gc.collect()
_logger.info(f"成功合并 {len(bl_numbers)} 个PDF文件,文件名: {pdf_filename}")
else:
_logger.warning("没有有效的PDF文件可以合并")
except Exception as e:
_logger.error(f"合并PDF文件失败: {str(e)}")
finally:
# 清理临时文件
if temp_file_path and os.path.exists(temp_file_path):
try:
os.remove(temp_file_path)
_logger.info(f"已删除临时文件: {temp_file_path}")
except Exception as e:
_logger.warning(f"删除临时文件失败: {str(e)}")
def _match_bl_by_file_name(self, pdf_file_arr, bl_obj): def _match_bl_by_file_name(self, pdf_file_arr, bl_obj):
""" """
...@@ -701,50 +477,21 @@ class BatchGetPodInfoWizard(models.TransientModel): ...@@ -701,50 +477,21 @@ class BatchGetPodInfoWizard(models.TransientModel):
:param pdf_file_arr: PDF文件数组 [{'bill_number':'', 'filename':'', 'file_data':''}] :param pdf_file_arr: PDF文件数组 [{'bill_number':'', 'filename':'', 'file_data':''}]
:return: 处理后的数组 [{'bl': bl_obj, 'file_name': 'xxx.pdf', 'file_data': 'xxx', 'matched': True/False}] :return: 处理后的数组 [{'bl': bl_obj, 'file_name': 'xxx.pdf', 'file_data': 'xxx', 'matched': True/False}]
""" """
processed_files = [] common = self.env['common.common'].sudo()
for bl in bl_obj: return common.match_pod_files(pdf_file_arr, bl_obj, include_processing_failed=False)
select_bl_no = self.env['common.common'].sudo().process_match_str(bl.bl_no)
for pdf_file in pdf_file_arr:
# 尝试不同的字段名(API可能使用不同的字段名)
file_name = pdf_file.get('file_name') # 获取文件名
file_data = pdf_file.get('file_data') # 获取文件数据
bl_no = pdf_file.get('bl_no') # 获取提单号
if bl_no and select_bl_no == bl_no:
# 构建处理后的文件信息
processed_file = {
'bl': bl,
'file_name': file_name,
'file_data': file_data,
'bl_no': bl_no,
}
processed_files.append(processed_file)
break
return processed_files
def _sync_last_mile_pod(self, processed_files): def _sync_last_mile_pod(self, processed_files):
""" """
Sync pickup POD information # 同步货站提货POD信息 Sync pickup POD information # 同步货站提货POD信息
:param processed_files: 处理后的文件数组 :param processed_files: 处理后的文件数组
""" """
# return False#测试 先不同步 common = self.env['common.common'].sudo()
# 同步货站提货POD信息 common.push_sync_pod_task(
is_fail = [] # 同步失败 processed_files=processed_files,
for file_info in processed_files: file_type='货站提货POD',
if not file_info['bl']: pod_desc='货站提货POD',
continue filter_temu=False
bl = file_info['bl'] )
# 查找清关文件并执行同步
clearance_file = file_info.get('clearance_file')
if clearance_file:
try:
clearance_file.action_sync() # 同步货站提货POD
except Exception as e:
logging.info('_sync_last_mile_pod:%s' % e)
is_fail = True
break
_logger.info(f"Successfully synced POD for BL {bl.bl_no}")
if is_fail:
raise ValidationError('本次同步失败,请重试!')
def _check_target_texts_exist(self, pdf_binary, bl_no): def _check_target_texts_exist(self, pdf_binary, bl_no):
""" """
...@@ -2362,200 +2109,14 @@ class BatchGetPodInfoWizard(models.TransientModel): ...@@ -2362,200 +2109,14 @@ class BatchGetPodInfoWizard(models.TransientModel):
return False, [] return False, []
def _cleanup_temp_attachments(self, bl_objs=None): def _cleanup_temp_attachments(self, bl_objs=None):
""" common = self.env['common.common'].sudo()
清理与当前向导相关的临时附件,包括服务器和本地开发环境的物理文件 common.cleanup_temp_attachments(bl_objs)
"""
try:
attachments = self.env['ir.attachment'].search([
('res_model', '=', bl_objs[0]._name),
('res_id', 'in', bl_objs.ids),
('name', 'like', 'temp_pod_%')
])
if attachments:
# 删除数据库记录
attachments.unlink()
except Exception as e:
_logger.error(f"清理临时附件失败: {str(e)}")
def _serialize_processed_files(self, processed_files): def _serialize_processed_files(self, processed_files):
""" common = self.env['common.common'].sudo()
将processed_files序列化为JSON字符串,文件数据存储到临时附件中 return common.serialize_pod_processed_files(processed_files)
:param processed_files: 处理后的文件数组
:return: JSON字符串(只包含引用信息,不包含文件数据)
"""
# 注意:不在这里清理临时附件,因为预览时需要保留附件数据
# 只有在确认操作完成后才清理临时附件
serialized_data = []
for file_info in processed_files:
if not file_info.get('bl'):
continue
bl = file_info['bl']
file_data = file_info.get('file_data', '')
file_name = file_info.get('file_name', f"{bl.bl_no}.pdf")
# 将文件数据存储到临时附件中
attachment_id = None
if file_data:
try:
attachment = self.env['ir.attachment'].create({
'name': f"temp_pod_{bl.bl_no}_{int(time.time())}.pdf",
'datas': file_data,
'type': 'binary',
'res_model': bl._name,
'res_id': bl.id,
})
attachment_id = attachment.id
_logger.info(f"已创建临时附件存储文件: {attachment.name}, ID: {attachment_id}")
except Exception as e:
_logger.error(f"创建临时附件失败: {str(e)}")
else:
_logger.warning(f"提单 {bl.bl_no} 的文件数据为空,无法创建附件")
data = {
'bl_id': bl.id,
'bl_no': bl.bl_no,
'file_name': file_name,
'attachment_id': attachment_id, # 存储附件ID而不是文件数据
}
# OCR文本数据量小,可以直接存储
if 'ocr_texts' in file_info:
data['ocr_texts'] = file_info['ocr_texts']
# 保存valid_packages的ID列表(记录集对象无法直接序列化)
if 'valid_packages' in file_info and file_info['valid_packages']:
valid_packages = file_info['valid_packages']
# 如果是记录集对象,提取ID列表
if hasattr(valid_packages, 'ids'):
data['valid_package_ids'] = valid_packages.ids
elif isinstance(valid_packages, list):
# 如果是列表,提取每个对象的ID
data['valid_package_ids'] = [p.id for p in valid_packages if hasattr(p, 'id')]
else:
data['valid_package_ids'] = []
_logger.info(
f"序列化时保存valid_packages: 提单 {bl.bl_no}, 满足条件的小包ID: {data['valid_package_ids']}")
serialized_data.append(data)
return json.dumps(serialized_data, ensure_ascii=False)
def _deserialize_processed_files(self, json_data): def _deserialize_processed_files(self, json_data):
""" common = self.env['common.common'].sudo()
将JSON字符串反序列化为processed_files(从附件中读取文件数据) return common.deserialize_pod_processed_files(json_data)
:param json_data: JSON字符串
:return: 处理后的文件数组
"""
if not json_data:
return []
try:
serialized_data = json.loads(json_data)
processed_files = []
for data in serialized_data:
bl_id = data.get('bl_id')
attachment_id = data.get('attachment_id')
if bl_id:
bl = self.env['cc.bl'].browse(bl_id)
if bl.exists():
# 从附件中读取文件数据
file_data = ''
if attachment_id:
try:
attachment = self.env['ir.attachment'].browse(attachment_id)
if attachment.exists():
# attachment.datas 已经是 base64 编码的字符串
file_data = attachment.datas
_logger.info(
f"从附件读取文件: {attachment.name}, ID: {attachment_id}, 数据长度: {len(file_data) if file_data else 0}")
else:
_logger.warning(f"附件不存在: {attachment_id}")
except Exception as e:
_logger.error(f"读取附件失败: {str(e)}")
else:
_logger.warning(f"提单 {bl.bl_no} 没有附件ID,无法读取文件数据")
file_info = {
'bl': bl,
'bl_no': data.get('bl_no', ''),
'file_name': data.get('file_name', ''),
'file_data': file_data,
}
# 如果有OCR文本,也恢复
if 'ocr_texts' in data:
file_info['ocr_texts'] = data['ocr_texts']
# 恢复valid_packages(从ID列表重建记录集对象)
if 'valid_package_ids' in data and data['valid_package_ids']:
valid_package_ids = data['valid_package_ids']
# 重建记录集对象
valid_packages = self.env['cc.ship.package'].browse(valid_package_ids)
file_info['valid_packages'] = valid_packages
_logger.info(
f"反序列化时恢复valid_packages: 提单 {bl.bl_no}, 满足条件的小包ID: {valid_package_ids}, 数量: {len(valid_packages)}")
processed_files.append(file_info)
return processed_files
except Exception as e:
_logger.error(f"反序列化processed_files失败: {str(e)}")
return []
@api.model
def cron_cleanup_temp_attachments(self):
"""
定时清理向导生成的临时附件
每天早上8点执行,删除1天之前创建的temp_pod_开头的附件
"""
try:
# 计算1天前的时间(前一天23:59:59)
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
one_day_ago = today + timedelta(days=2) - timedelta(seconds=1) # 前一天23:59:59
_logger.info(f"开始执行定时清理临时附件任务,清理时间点: {one_day_ago.strftime('%Y-%m-%d %H:%M:%S')}")
# 构建SQL查询
sql_query = """
SELECT id, name, res_model, res_id, create_date, store_fname
FROM ir_attachment
WHERE res_model = 'batch.get.pod.info.wizard'
AND create_date < '%s'
ORDER BY create_date DESC
""" % (one_day_ago.strftime('%Y-%m-%d %H:%M:%S'))
# 执行SQL查询
self.env.cr.execute(sql_query)
sql_results = self.env.cr.fetchall()
# 将SQL结果转换为Odoo记录集
if sql_results:
attachment_ids = [result[0] for result in sql_results]
temp_attachments = self.env['ir.attachment'].sudo().browse(attachment_ids)
attachment_count = len(temp_attachments)
_logger.info(f"找到 {attachment_count} 个{one_day_ago.strftime('%Y-%m-%d')}之前创建的临时附件,开始清理")
# 删除物理文件
for attachment in temp_attachments:
try:
# 获取附件的物理文件路径
if hasattr(attachment, 'store_fname') and attachment.store_fname:
# Odoo 12+ 使用 store_fname
file_path = attachment.store_fname
elif hasattr(attachment, 'datas_fname') and attachment.datas_fname:
# 旧版本使用 datas_fname
file_path = attachment.datas_fname
else:
# 尝试从 name 字段构建路径
file_path = attachment.name
# 构建完整的文件路径
import os
from odoo.tools import config
# 获取 Odoo 数据目录
data_dir = config.filestore(self.env.cr.dbname)
if data_dir and file_path:
full_path = os.path.join(data_dir, file_path)
if os.path.exists(full_path):
os.remove(full_path)
except Exception as file_e:
_logger.warning(f"删除物理文件失败 {attachment.name}: {str(file_e)}")
# 删除数据库记录
temp_attachments.unlink()
except Exception as e:
_logger.error(f"定时清理临时附件失败: {str(e)}")
...@@ -31,8 +31,11 @@ class Order_dispose(object): ...@@ -31,8 +31,11 @@ class Order_dispose(object):
try: try:
data = json.loads(data) data = json.loads(data)
logging.info('mail_push_data: %s', data) logging.info('mail_push_data: %s', data)
action_type = data.get('action_type')
if action_type == 'sync_last_mile_pod':
self._sync_last_mile_pod_from_queue(data)
return res_data
ship_packages = eval(data['ship_packages']) if data.get('ship_packages') else [] # 小包 ship_packages = eval(data['ship_packages']) if data.get('ship_packages') else [] # 小包
action_type = data.get('action_type') # 类型
utc_time = data.get('utc_time') utc_time = data.get('utc_time')
bl_obj = self.odoo_db.env['cc.bl'] bl_obj = self.odoo_db.env['cc.bl']
if action_type and not utc_time: if action_type and not utc_time:
...@@ -47,11 +50,38 @@ class Order_dispose(object): ...@@ -47,11 +50,38 @@ class Order_dispose(object):
logging.info('user_login: %s', user_login) logging.info('user_login: %s', user_login)
pod_node_id = data.get('pod_node_id') pod_node_id = data.get('pod_node_id')
bl_record.mail_auto_push(utc_time, ship_packages, action_type, user_login, user_login or config.pda_db_user, bl_record.mail_auto_push(utc_time, ship_packages, action_type, user_login, user_login or config.pda_db_user,
pod_node_id=pod_node_id) pod_node_id=pod_node_id)
except Exception as ex: except Exception as ex:
logging.error('mail_auto_push error:%s' % str(ex)) logging.error('mail_auto_push error:%s' % str(ex))
return res_data return res_data
def _sync_last_mile_pod_from_queue(self, data):
bl_ids = data.get('ids') or []
if not bl_ids:
return
try:
bl_model = self.odoo_db.env['cc.bl']
clearance_model = self.odoo_db.env['cc.clearance.file']
bl_records = bl_model.browse(bl_ids)
non_temu_ids = [bl.id for bl in bl_records if getattr(bl, 'bl_type', False) != 'temu']
if not non_temu_ids:
return
clearance_ids = clearance_model.search([
('bl_id', 'in', non_temu_ids),
('file_name', '=', data.get('file_type')),
])
logging.info("已查询到需要同步的清关文件记录: %s", clearance_ids)
if not clearance_ids:
return
clearance_records = clearance_model.browse(clearance_ids)
for clearance_file in clearance_records:
try:
clearance_file.action_sync()
except Exception as ex:
logging.error('sync_last_mile_pod action_sync error:%s' % str(ex))
except Exception as ex:
logging.error('sync_last_mile_pod_from_queue error:%s' % str(ex))
try: try:
pool = redis.ConnectionPool(**config.redis_options) pool = redis.ConnectionPool(**config.redis_options)
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论