dy-dashboard/scripts/import_spot_confirmation_history.py
2026-10-28 11:14:17 +08:00

281 lines
12 KiB
Python

"""Import the WPS cell-image sampling workbook as historical spot records.
The workbook's application numbers belong to a different database and are never
used as local IDs. Historical applicant names are stored as plain text.
"""
import argparse
import datetime as dt
import hashlib
import os
import posixpath
import re
import sys
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
NS = {
'm': 'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
'etc': 'http://www.wps.cn/officeDocument/2017/etCustomData',
'xdr': 'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing',
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
'rel': 'http://schemas.openxmlformats.org/package/2006/relationships',
}
EPOCH = dt.datetime(1899, 12, 30)
ROOT = Path(__file__).resolve().parents[1]
UPLOAD_DIR = ROOT / 'static' / 'uploads' / 'spot_application'
def cell_image_map(archive):
rel_root = ET.fromstring(archive.read('xl/_rels/cellimages.xml.rels'))
relationships = {
rel.attrib['Id']: posixpath.normpath(posixpath.join('xl', rel.attrib['Target']))
for rel in rel_root
}
root = ET.fromstring(archive.read('xl/cellimages.xml'))
result = {}
for image in root.findall('etc:cellImage', NS):
name = image.find('.//xdr:cNvPr', NS).attrib['name']
rid = image.find('.//a:blip', NS).attrib['{' + NS['r'] + '}embed']
result[name] = relationships[rid]
return result
def excel_date(value):
if not value:
return None
if re.fullmatch(r'\d+', value):
return (EPOCH + dt.timedelta(days=float(value))).date()
for fmt in ('%Y-%m-%d', '%Y/%m/%d', '%Y%m%d'):
try:
return dt.datetime.strptime(value.strip(), fmt).date()
except ValueError:
pass
match = re.fullmatch(r'(\d{1,2})\.(\d{1,2})', value.strip())
if match:
month, day = map(int, match.groups())
year = dt.date.today().year
try:
candidate = dt.date(year, month, day)
if candidate > dt.date.today() + dt.timedelta(days=45):
candidate = dt.date(year - 1, month, day)
return candidate
except ValueError:
return None
return None
def arrival_date(value, application_date):
match = re.fullmatch(r'(\d{1,2})\.(\d{1,2})', (value or '').strip())
if match and application_date:
month, day = map(int, match.groups())
try:
candidate = dt.date(application_date.year, month, day)
except ValueError:
return None
if candidate < application_date - dt.timedelta(days=45):
try:
candidate = dt.date(application_date.year + 1, month, day)
except ValueError:
return None
return candidate
return excel_date(value)
def read_rows(path):
with zipfile.ZipFile(path) as archive:
strings_root = ET.fromstring(archive.read('xl/sharedStrings.xml'))
strings = [''.join(t.text or '' for t in item.findall('.//m:t', NS))
for item in strings_root.findall('m:si', NS)]
images = cell_image_map(archive)
sheet = ET.fromstring(archive.read('xl/worksheets/sheet1.xml'))
sheet_rows = sheet.findall('.//m:sheetData/m:row', NS)
if not sheet_rows:
raise ValueError('工作表为空')
parsed = []
missing_images = []
for row in sheet_rows[1:]:
values = {}
for cell in row.findall('m:c', NS):
col = re.match(r'[A-Z]+', cell.attrib['r']).group()
value_node = cell.find('m:v', NS)
formula = cell.find('m:f', NS)
value = value_node.text if value_node is not None else ''
if cell.attrib.get('t') == 's' and value:
value = strings[int(value)]
if col == 'E' and formula is not None:
match = re.search(r'ID_[A-F0-9]+', formula.text or '')
value = match.group(0) if match else ''
values[col] = value or ''
source_id = values.get('A', '').strip()
if not source_id:
continue
image_id = values.get('E', '').strip()
image_member = images.get(image_id)
if image_id and not image_member:
missing_images.append((source_id, image_id))
parsed.append((source_id, values, image_member))
if missing_images:
raise ValueError(f'有图片引用未找到:{missing_images[:10]}')
return archive, parsed
def build_application(source_id, values, image_path):
app_date = excel_date(values.get('B', ''))
quantity = values.get('G', '').strip()
if quantity and not re.fullmatch(r'\d+', quantity):
quantity = '' # Non-numeric notes do not map to the integer quantity field.
product_name = values.get('D', '').strip()
if len(product_name) > 300:
raise ValueError(f'源编号{source_id}产品名称过长')
applicant_name = values.get('K', '').strip()
if len(applicant_name) > 200:
raise ValueError(f'源编号{source_id}申请人姓名过长')
price_text = values.get('O', '').strip()
price = price_text if re.fullmatch(r'\d{1,14}(?:\.\d{1,4})?', price_text) else None
return app_date, {
'application_date': app_date,
'department': values.get('I', '').strip(),
'group_name': values.get('J', '').strip(),
'applicant_name': applicant_name,
'brand': values.get('C', '').strip(),
'product_name': product_name,
'manufacturer': values.get('L', '').strip(),
'image_path': image_path,
'product_link': values.get('H', '').strip(),
'sample_quantity': int(quantity) if quantity else 0,
'barcode': values.get('F', '').strip(),
'recipient_info': values.get('M', '').strip(),
'status': '通过',
}, {
'sample_status': values.get('N', '').strip(),
'product_unit_price': price,
'oa_approval_no': values.get('P', '').strip(),
'express_tracking_no': values.get('Q', '').strip(),
'arrival_date': arrival_date(values.get('R', ''), app_date),
'remark': values.get('S', '').strip(),
'receiving_department': values.get('T', '').strip(),
'recipient': values.get('U', '').strip(),
'sample_confirmed': values.get('V', '').strip(),
# Keep legacy free-text values verbatim, including values outside today's dropdown.
'sample_confirmation_reason': values.get('W', '').strip(),
'product_code': values.get('X', '').strip(),
'final_product_name': values.get('Y', '').strip(),
'platform': values.get('Z', '').strip(),
}
def import_rows(workbook, dry_run):
sys.path.insert(0, str(ROOT))
if '--local' in sys.argv:
import test_wsgi # noqa: F401
from services.db import get_db
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(workbook) as archive:
from openpyxl import load_workbook
from services.spot_confirmation_import import FIELDS
check_wb = load_workbook(workbook, read_only=True, data_only=False)
check_sheet = check_wb['采样确认'] if '采样确认' in check_wb.sheetnames else check_wb.worksheets[0]
headers = [cell.value for cell in next(check_sheet.iter_rows())]
check_wb.close()
expected_headers = [field[0] for field in FIELDS]
if headers != expected_headers:
raise ValueError(f'模板表头不匹配,预期{expected_headers},实际{headers}')
rows = read_rows(workbook)[1]
prepared = []
for source_id, values, image_member in rows:
ext = Path(image_member).suffix.lower() if image_member else ''
content = archive.read(image_member) if image_member else b''
digest = hashlib.sha256(content).hexdigest()[:16] if content else ''
filename = f'history_{source_id}_{digest}{ext}' if ext else ''
image_path = f'/static/uploads/spot_application/{filename}' if filename else ''
app_date, app_values, confirmation = build_application(source_id, values, image_path)
prepared.append((source_id, image_member, filename, app_date, app_values, confirmation))
conn = get_db()
copied = []
try:
with conn.cursor() as cur:
cur.execute("SHOW COLUMNS FROM spot_applications LIKE 'applicant_name'")
if not cur.fetchone():
raise RuntimeError('数据库缺少 applicant_name 字段,先部署兼容迁移')
cur.execute('SELECT id FROM dashboard_users WHERE id=1')
actor = cur.fetchone()
if not actor:
raise RuntimeError('导入审计账号 id=1 不存在')
if dry_run:
print(f'预检通过:{len(prepared)}行;图片引用完整;数据库可连接;未写入。')
conn.rollback()
return
inserted = skipped = 0
for source_id, image_member, filename, app_date, app_values, confirmation in prepared:
full_image = UPLOAD_DIR / filename if filename else None
if full_image and not full_image.exists():
with open(full_image, 'xb') as f:
f.write(archive.read(image_member))
copied.append(full_image)
elif full_image:
expected = hashlib.sha256(archive.read(image_member)).hexdigest()[:16]
actual = hashlib.sha256(full_image.read_bytes()).hexdigest()[:16]
if actual != expected:
raise ValueError(f'已有图片校验失败:{filename}')
if filename:
cur.execute('SELECT id FROM spot_applications WHERE image_path=%s LIMIT 1',
(app_values['image_path'],))
existing = cur.fetchone()
if existing:
app_id = existing['id']
app_update = dict(app_values)
app_update.pop('applicant_user_id', None)
app_assign = ','.join(f'`{key}`=%s' for key in app_update)
cur.execute(f'UPDATE spot_applications SET {app_assign} WHERE id=%s',
tuple(app_update.values()) + (app_id,))
cur.execute('INSERT IGNORE INTO spot_confirmations(application_id) VALUES (%s)', (app_id,))
conf_assign = ','.join(f'`{key}`=%s' for key in confirmation)
cur.execute(f'UPDATE spot_confirmations SET {conf_assign} WHERE application_id=%s',
tuple(confirmation.values()) + (app_id,))
skipped += 1
continue
app_values['applicant_user_id'] = 1 # Required audit FK; historical name remains separate text.
columns = ','.join(f'`{key}`' for key in app_values)
marks = ','.join(['%s'] * len(app_values))
cur.execute(f'INSERT INTO spot_applications ({columns}) VALUES ({marks})',
tuple(app_values.values()))
app_id = cur.lastrowid
cfields = {'application_id': app_id, **confirmation}
ccolumns = ','.join(f'`{key}`' for key in cfields)
cmarks = ','.join(['%s'] * len(cfields))
cur.execute(f'INSERT INTO spot_confirmations ({ccolumns}) VALUES ({cmarks})',
tuple(cfields.values()))
inserted += 1
conn.commit()
print(f'导入完成:新增{inserted}条,按图片路径跳过已有{skipped}条。')
except Exception:
conn.rollback()
for path in copied:
try:
path.unlink()
except OSError:
pass
raise
finally:
conn.close()
def main():
parser = argparse.ArgumentParser()
parser.add_argument('workbook')
parser.add_argument('--local', action='store_true')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
import_rows(args.workbook, args.dry_run)
if __name__ == '__main__':
main()