fix: sync backup production numbers to date-only orders

This commit is contained in:
Codex 2026-10-13 12:11:16 +08:00
parent 910612b03f
commit 071d9965fc
3 changed files with 210 additions and 54 deletions

View File

@ -5,7 +5,7 @@ import re
import unicodedata import unicodedata
from datetime import datetime from datetime import datetime
from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template from flask import Blueprint, request, jsonify, session, redirect, url_for, render_template
from services.db import query, execute, add_operation_log from services.db import query, execute, add_operation_log, get_db
from services.field_perms import filter_rows as filter_field_rows from services.field_perms import filter_rows as filter_field_rows
from services.sales_calc import (compute_product_sales_cached, from services.sales_calc import (compute_product_sales_cached,
compute_product_sales_extended_cached) compute_product_sales_extended_cached)
@ -3879,7 +3879,8 @@ def _get_ordered_backup_time(product_code, backup_id=None, ordered_at=None):
其次按传入 ordered_at,最后回退到该商品最近一次下单。 其次按传入 ordered_at,最后回退到该商品最近一次下单。
""" """
if backup_id: if backup_id:
rows = query("SELECT ordered_at FROM pending_orders WHERE id=%s AND status='ordered'", (backup_id,)) rows = query("SELECT ordered_at FROM pending_orders WHERE id=%s AND product_code=%s AND status='ordered'",
(backup_id, product_code))
elif ordered_at: elif ordered_at:
return str(ordered_at)[:19] return str(ordered_at)[:19]
else: else:
@ -3893,38 +3894,97 @@ def _get_ordered_backup_time(product_code, backup_id=None, ordered_at=None):
return value.strftime('%Y-%m-%d %H:%M:%S') if hasattr(value, 'strftime') else str(value)[:19] return value.strftime('%Y-%m-%d %H:%M:%S') if hasattr(value, 'strftime') else str(value)[:19]
def _backup_linked_order_ids(read, table, product_code, ordered_at):
"""Prefer the exact order time; date-only legacy rows need a unique source."""
code_field, time_field = {
'domestic_orders': ('货品编号', '计划下单时间'),
'overseas_orders': ('产品编码', '计划下单时间'),
'packaging_orders': ('来源产品编码', '下单时间'),
}[table]
rows = read(f'SELECT id FROM {table} WHERE `{code_field}`=%s AND `{time_field}`=%s',
(product_code, ordered_at))
if rows:
return [row['id'] for row in rows]
day = ordered_at[:10]
legacy = read(f'SELECT id FROM {table} WHERE `{code_field}`=%s AND TRIM(`{time_field}`)=%s',
(product_code, day))
if not legacy:
return []
sources = read(
"SELECT id FROM pending_orders WHERE product_code=%s "
"AND status IN ('ordered','rolled_back') "
"AND ordered_at >= %s AND ordered_at < DATE_ADD(%s, INTERVAL 1 DAY)",
(product_code, day, day),
)
# 成品订单须唯一;同一备份可以生成多条包材订单。
if len(sources) != 1 or (table != 'packaging_orders' and len(legacy) != 1):
raise ValueError('同一商品当天有多笔记录,日期不足以确定对应订单,请核对计划下单时间')
return [row['id'] for row in legacy]
@purchase_bp.route('/api/purchase/backup/update_prod_no', methods=['POST']) @purchase_bp.route('/api/purchase/backup/update_prod_no', methods=['POST'])
@login_required @login_required
def backup_update_prod_no(): def backup_update_prod_no():
denied = _require_purchase_operation('purchase_ready', 'edit') denied = _require_purchase_operation('purchase_ready', 'edit')
if denied: if denied:
return denied return denied
"""Update only order rows created by this exact backup order time.""" """Save the backup and uniquely linked order numbers in one transaction."""
data = request.json data = request.get_json(silent=True) or {}
product_code = data.get('product_code', '') product_code = data.get('product_code', '')
prod_no = data.get('production_order_no', '') prod_no = data.get('production_order_no', '')
backup_id = data.get('backup_id') backup_id = data.get('backup_id')
ordered_at = data.get('ordered_at', '') ordered_at = data.get('ordered_at', '')
if not product_code: if not product_code:
return jsonify({"code": 400, "msg": "参数错误"}) return jsonify({"code": 400, "msg": "参数错误"})
ordered_at = _get_ordered_backup_time(product_code, backup_id=backup_id, ordered_at=ordered_at) conn = get_db()
if not ordered_at: counts = {}
return jsonify({"code": 404, "msg": "未找到对应下单时间"}) try:
conn.begin()
with conn.cursor() as cur:
def read(sql, params):
cur.execute(sql, params)
return cur.fetchall()
if backup_id: if backup_id:
query("""UPDATE pending_orders SET production_order_no=%s WHERE id=%s AND status='ordered'""", backups = read("SELECT id, ordered_at FROM pending_orders WHERE id=%s "
(prod_no, backup_id)) "AND product_code=%s AND status='ordered' FOR UPDATE", (backup_id, product_code))
elif ordered_at:
backups = read("SELECT id, ordered_at FROM pending_orders WHERE product_code=%s "
"AND status='ordered' AND ordered_at=%s FOR UPDATE", (product_code, ordered_at))
else: else:
query("""UPDATE pending_orders SET production_order_no=%s backups = read("SELECT id, ordered_at FROM pending_orders WHERE product_code=%s "
WHERE product_code=%s AND status='ordered' AND ordered_at=%s""", "AND status='ordered' ORDER BY ordered_at DESC,id DESC LIMIT 1 FOR UPDATE", (product_code,))
(prod_no, product_code, ordered_at)) if len(backups) != 1 or not backups[0].get('ordered_at'):
query("""UPDATE domestic_orders SET `旺店通生产单号`=%s conn.rollback()
WHERE `货品编号`=%s AND `计划下单时间`=%s""", (prod_no, product_code, ordered_at)) return jsonify({"code": 404, "msg": "未找到唯一对应的下单备份"})
query("""UPDATE overseas_orders SET `旺店通生产单号`=%s backup = backups[0]
WHERE `产品编码`=%s AND `计划下单时间`=%s""", (prod_no, product_code, ordered_at)) time = backup['ordered_at']
query("""UPDATE packaging_orders SET `生产单号`=%s ordered_at = time.strftime('%Y-%m-%d %H:%M:%S') if hasattr(time, 'strftime') else str(time)[:19]
WHERE `来源产品编码`=%s AND `下单时间`=%s""", (prod_no, product_code, ordered_at)) targets = {table: _backup_linked_order_ids(read, table, product_code, ordered_at)
for table in ('domestic_orders', 'overseas_orders', 'packaging_orders')}
cur.execute('UPDATE pending_orders SET production_order_no=%s WHERE id=%s', (prod_no, backup['id']))
for table, ids in targets.items():
counts[table] = len(ids)
if ids:
field = '生产单号' if table == 'packaging_orders' else '旺店通生产单号'
placeholders = ','.join(['%s'] * len(ids))
cur.execute(f'UPDATE {table} SET `{field}`=%s WHERE id IN ({placeholders})',
(prod_no, *ids))
conn.commit()
except ValueError as exc:
conn.rollback()
return jsonify({"code": 409, "msg": str(exc)})
except Exception:
conn.rollback()
raise
finally:
conn.close()
add_operation_log('生产单号更新', f'{product_code}/{ordered_at}: {prod_no}') add_operation_log('生产单号更新', f'{product_code}/{ordered_at}: {prod_no}')
return jsonify({"code": 0, "msg": "保存成功"}) msg = '生产单号已保存,已同步对应订单'
if not any(counts.values()):
msg = '生产单号已保存,但未找到可同步的订单,请核对订单记录'
return jsonify({"code": 0, "msg": msg, "data": {"synced": counts}})
@purchase_bp.route('/api/purchase/backup/audit_material_price', methods=['POST']) @purchase_bp.route('/api/purchase/backup/audit_material_price', methods=['POST'])

View File

@ -19483,25 +19483,13 @@ window.saveBackupProdNo = function(bid, code) {
inp.blur(); inp.blur();
// 更新缓存数据 // 更新缓存数据
_bkItems().forEach(function(it) { _bkItems().forEach(function(it) {
if (it.product_code === code) it.production_order_no = val; if (String(it.id) === String(bid)) it.production_order_no = val;
}); });
_renderFilteredBackup();
_refreshLinkedOrderLists(); _refreshLinkedOrderLists();
// 同步更新表格中的生产单号列 showCenteredAlert('保存成功', '✅ ' + (res.msg || '生产单号已保存'));
var tbody = _bkEl('Tbody');
if (tbody) {
tbody.querySelectorAll('tr').forEach(function(tr) {
var codeEl = tr.querySelector('code');
if (codeEl && codeEl.textContent.trim() === code) {
var tds = tr.querySelectorAll('td');
// 生产单号是第7列(序号0,产品类型1,货品编号2,货品名称3,下单时间4,下单人5,生产单号6)
if (tds.length > 6) {
tds[6].textContent = val || '-';
}
}
});
}
showCenteredAlert('保存成功', '✅ 生产单号已保存');
} else { } else {
showCenteredAlert('保存失败', res.msg || '生产单号保存失败');
inp.style.borderColor = '#f5222d'; inp.style.borderColor = '#f5222d';
setTimeout(function(){ inp.style.borderColor = ''; }, 1500); setTimeout(function(){ inp.style.borderColor = ''; }, 1500);
} }
@ -19610,12 +19598,12 @@ window.saveBackupAll = function(bid, code, saveBtn) {
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = '💾 保存'; } if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = '💾 保存'; }
var hasError = results.some(function(r) { return r.code !== 0; }); var hasError = results.some(function(r) { return r.code !== 0; });
if (hasError) { if (hasError) {
showCenteredAlert('保存失败', '❌ 部分数据保存失败,请重试'); showCenteredAlert('保存失败', '❌ ' + results.filter(function(r) { return r.code !== 0; }).map(function(r) { return r.msg || '部分数据保存失败'; }).join(';'));
} else { } else {
// ---- 同步更新前端缓存,确保关闭后再打开看到新数据 ---- // ---- 同步更新前端缓存,确保关闭后再打开看到新数据 ----
var newProdNo = prodNoInp ? prodNoInp.value.trim() : ''; var newProdNo = prodNoInp ? prodNoInp.value.trim() : '';
_bkItems().forEach(function(it) { _bkItems().forEach(function(it) {
if (it.product_code === code) { if (String(it.id) === String(bid)) {
// 更新生产单号 // 更新生产单号
it.production_order_no = newProdNo; it.production_order_no = newProdNo;
// 更新包材采购单号(bom_items JSON内) // 更新包材采购单号(bom_items JSON内)
@ -19636,27 +19624,15 @@ window.saveBackupAll = function(bid, code, saveBtn) {
} }
} }
}); });
// 同步更新表格中的生产单号列 _renderFilteredBackup();
var tbody = _bkEl('Tbody');
if (tbody) {
tbody.querySelectorAll('tr').forEach(function(tr) {
var codeEl = tr.querySelector('code');
if (codeEl && codeEl.textContent.trim() === code) {
var tds = tr.querySelectorAll('td');
if (tds.length > 6) {
tds[6].textContent = newProdNo || '-';
}
}
});
}
// 后台刷新列表数据(用户下次查看时拉取最新) // 后台刷新列表数据(用户下次查看时拉取最新)
if (typeof loadBackupList === 'function') { if (typeof loadBackupList === 'function') {
loadBackupList(); loadBackupList(false);
} }
_refreshLinkedOrderLists(); _refreshLinkedOrderLists();
// 包材订单管理数据已同步(旺店通单号等),保留当前筛选并重新拉取数据 // 包材订单管理数据已同步(旺店通单号等),保留当前筛选并重新拉取数据
var detailOverlay = saveBtn && saveBtn.closest('.modal-overlay'); var detailOverlay = saveBtn && saveBtn.closest('.modal-overlay');
showCenteredAlert('保存成功', '✅ 生产单号、采购单号已保存'); showCenteredAlert('保存成功', '✅ ' + (prodNoInp && results[0] ? results[0].msg : '生产单号、采购单号已保存'));
if (detailOverlay) detailOverlay.remove(); if (detailOverlay) detailOverlay.remove();
} }
}).catch(function() { }).catch(function() {

View File

@ -0,0 +1,120 @@
"""Keep production numbers linked to one order, including date-only history."""
import unittest
from datetime import datetime
from unittest import mock
from flask import Flask
import routes.purchase as purchase
class BackupProductionNoSyncTest(unittest.TestCase):
def test_exact_time_wins_without_using_date_fallback(self):
read = mock.Mock(return_value=[{'id': 9}])
self.assertEqual(purchase._backup_linked_order_ids(
read, 'domestic_orders', 'P1', '2026-09-27 18:29:13'), [9])
read.assert_called_once()
self.assertEqual(read.call_args.args[1], ('P1', '2026-09-27 18:29:13'))
def test_legacy_date_matches_only_unique_backup_and_product_order(self):
read = mock.Mock(side_effect=[[], [{'id': 2804}], [{'id': 1555}]])
self.assertEqual(purchase._backup_linked_order_ids(
read, 'domestic_orders', 'P1', '2026-09-27 18:29:13'), [2804])
self.assertIn('TRIM(`计划下单时间`)=%s', read.call_args_list[1].args[0])
self.assertEqual(read.call_args_list[1].args[1], ('P1', '2026-09-27'))
def test_ambiguous_same_day_backups_are_never_broadly_updated(self):
read = mock.Mock(side_effect=[[], [{'id': 9}], [{'id': 1}, {'id': 2}]])
with self.assertRaises(ValueError):
purchase._backup_linked_order_ids(read, 'domestic_orders', 'P1', '2026-09-27 18:29:13')
def test_ambiguous_legacy_product_orders_are_rejected(self):
read = mock.Mock(side_effect=[[], [{'id': 9}, {'id': 10}], [{'id': 1}]])
with self.assertRaises(ValueError):
purchase._backup_linked_order_ids(read, 'overseas_orders', 'P1', '2026-09-27 18:29:13')
def test_multiple_packaging_rows_can_belong_to_one_backup(self):
read = mock.Mock(side_effect=[[], [{'id': 9}, {'id': 10}], [{'id': 1}]])
self.assertEqual(purchase._backup_linked_order_ids(
read, 'packaging_orders', 'P1', '2026-09-27 18:29:13'), [9, 10])
def call_save(self, replies, execute_error=None):
app = Flask(__name__)
app.secret_key = 'test-only'
conn = mock.MagicMock()
cur = conn.cursor.return_value.__enter__.return_value
cur.fetchall.side_effect = replies
if execute_error:
cur.execute.side_effect = execute_error
with app.test_request_context(json={
'product_code': 'P1', 'backup_id': 1555, 'production_order_no': 'PS2026093029'
}), mock.patch.object(purchase, '_require_purchase_operation', return_value=None), \
mock.patch.object(purchase, 'get_db', return_value=conn), \
mock.patch.object(purchase, 'add_operation_log'):
response = purchase.backup_update_prod_no.__wrapped__()
return response.get_json(), conn, cur
def test_save_and_sync_commit_together_using_target_ids(self):
body, conn, cur = self.call_save([
[{'id': 1555, 'ordered_at': datetime(2026, 9, 27, 18, 29, 13)}],
[], [{'id': 2804}], [{'id': 1555}], # domestic legacy date
[], [], # no overseas order
[{'id': 3}, {'id': 4}], # exact packaging rows
])
self.assertEqual(body['code'], 0)
self.assertEqual(body['data']['synced'], {
'domestic_orders': 1, 'overseas_orders': 0, 'packaging_orders': 2,
})
writes = [call.args for call in cur.execute.call_args_list if call.args[0].startswith('UPDATE')]
self.assertEqual([args[1] for args in writes], [
('PS2026093029', 1555), ('PS2026093029', 2804), ('PS2026093029', 3, 4)
])
for sql, params in writes:
self.assertEqual(sql.count('%s'), len(params))
conn.commit.assert_called_once()
conn.rollback.assert_not_called()
conn.close.assert_called_once()
def test_ambiguity_prevents_even_the_backup_write(self):
body, conn, cur = self.call_save([
[{'id': 1555, 'ordered_at': datetime(2026, 9, 27, 18, 29, 13)}],
[], [{'id': 2804}], [{'id': 1555}, {'id': 1556}],
])
self.assertEqual(body['code'], 409)
self.assertFalse(any(call.args[0].startswith('UPDATE') for call in cur.execute.call_args_list))
conn.commit.assert_not_called()
conn.rollback.assert_called_once()
def test_wrong_product_or_missing_backup_is_not_saved(self):
body, conn, cur = self.call_save([[]])
self.assertEqual(body['code'], 404)
self.assertIn('product_code=%s', cur.execute.call_args.args[0])
self.assertEqual(cur.execute.call_args.args[1], (1555, 'P1'))
conn.commit.assert_not_called()
conn.rollback.assert_called_once()
def test_write_failure_rolls_back_all_related_updates(self):
app = Flask(__name__)
conn = mock.MagicMock()
cur = conn.cursor.return_value.__enter__.return_value
cur.fetchall.side_effect = [
[{'id': 1555, 'ordered_at': datetime(2026, 9, 27, 18, 29, 13)}],
[{'id': 2804}], [], [], [], [],
]
def execute(sql, params):
if sql.startswith('UPDATE domestic_orders'):
raise RuntimeError('database write failure')
cur.execute.side_effect = execute
with app.test_request_context(json={'product_code': 'P1', 'backup_id': 1555, 'production_order_no': 'PS1'}), \
mock.patch.object(purchase, '_require_purchase_operation', return_value=None), \
mock.patch.object(purchase, 'get_db', return_value=conn), \
mock.patch.object(purchase, 'add_operation_log') as audit:
with self.assertRaises(RuntimeError):
purchase.backup_update_prod_no.__wrapped__()
audit.assert_not_called()
conn.commit.assert_not_called()
conn.rollback.assert_called_once()
conn.close.assert_called_once()
if __name__ == '__main__':
unittest.main()