From 8d96442205be689c73e119c50187054203d5abba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 27 Jun 2026 11:03:50 +0000 Subject: [PATCH] Fix destructive sales and backup restore regressions Co-authored-by: Xianist Lab --- inventory/templates/inventory/sale_form.html | 4 +- .../templates/inventory/sale_item_form.html | 11 +- .../inventory/system/backup_list.html | 10 +- .../inventory/system/delete_backup.html | 27 ++++ inventory/tests/test_sale_status.py | 43 ++++++ inventory/tests/test_sales_balance_payment.py | 8 + inventory/tests/test_views.py | 92 ++++++++++++ inventory/views/sales.py | 140 +++++++++--------- inventory/views/system/backup.py | 111 +++++++++----- 9 files changed, 323 insertions(+), 123 deletions(-) create mode 100644 inventory/templates/inventory/system/delete_backup.html diff --git a/inventory/templates/inventory/sale_form.html b/inventory/templates/inventory/sale_form.html index 5675f91..28b8f57 100644 --- a/inventory/templates/inventory/sale_form.html +++ b/inventory/templates/inventory/sale_form.html @@ -1346,9 +1346,9 @@
{% if request.LANGUAGE_CODE == 'en' %}Payment Met {% if request.LANGUAGE_CODE == 'en' %}Balance{% else %}余额{% endif %} - diff --git a/inventory/templates/inventory/sale_item_form.html b/inventory/templates/inventory/sale_item_form.html index 6193151..f3c72c5 100644 --- a/inventory/templates/inventory/sale_item_form.html +++ b/inventory/templates/inventory/sale_item_form.html @@ -476,16 +476,19 @@

添加销售商品

- {% for item in sale.saleitems.all %} + {% for item in items %} {{ item.product.name }} {{ item.quantity }} {{ item.actual_price }} {{ item.subtotal }} - - - +
+ {% csrf_token %} + +
{% endfor %} diff --git a/inventory/templates/inventory/system/backup_list.html b/inventory/templates/inventory/system/backup_list.html index 587751f..bd9dbeb 100644 --- a/inventory/templates/inventory/system/backup_list.html +++ b/inventory/templates/inventory/system/backup_list.html @@ -148,7 +148,11 @@ @@ -160,13 +164,13 @@ const deleteBackupModal = new bootstrap.Modal(document.getElementById('deleteBackupModal')); const deleteButtons = document.querySelectorAll('.delete-backup'); const backupNameToDelete = document.getElementById('backup-name-to-delete'); - const confirmDeleteButton = document.getElementById('confirm-delete-backup'); + const deleteBackupForm = document.getElementById('delete-backup-form'); deleteButtons.forEach(button => { button.addEventListener('click', function() { const backupName = this.getAttribute('data-backup-name'); backupNameToDelete.textContent = backupName; - confirmDeleteButton.href = `/system/backup/delete/${backupName}/`; + deleteBackupForm.action = `/system/backup/delete/${backupName}/`; deleteBackupModal.show(); }); }); diff --git a/inventory/templates/inventory/system/delete_backup.html b/inventory/templates/inventory/system/delete_backup.html new file mode 100644 index 0000000..1f6d7b0 --- /dev/null +++ b/inventory/templates/inventory/system/delete_backup.html @@ -0,0 +1,27 @@ +{% extends 'inventory/base.html' %} + +{% block title %}删除备份 - {{ block.super }}{% endblock %} + +{% block content %} +
+
+
+
+
删除备份
+
+
+

确定要删除备份 {{ backup_name }} 吗?

+

此操作不可恢复。

+
+ {% csrf_token %} + +
+ 取消 + +
+
+
+
+
+
+{% endblock %} diff --git a/inventory/tests/test_sale_status.py b/inventory/tests/test_sale_status.py index 270e7a3..6828597 100644 --- a/inventory/tests/test_sale_status.py +++ b/inventory/tests/test_sale_status.py @@ -141,6 +141,19 @@ def test_cannot_delete_item_from_cancelled_sale(self): self.inventory.refresh_from_db() self.assertEqual(self.inventory.quantity, restored_quantity) + def test_get_does_not_delete_item_or_restore_inventory(self): + sale = self._make_sale(status='DRAFT') + item = sale.items.get() + self.inventory.refresh_from_db() + before = self.inventory.quantity + + response = self.client.get(reverse('sale_item_delete', args=[sale.id, item.id])) + + self.assertEqual(response.status_code, 405) + self.assertTrue(SaleItem.objects.filter(pk=item.pk).exists()) + self.inventory.refresh_from_db() + self.assertEqual(self.inventory.quantity, before) + def test_delete_item_persists_recalculated_total(self): sale = self._make_sale(status='DRAFT') extra = SaleItem.objects.create( @@ -160,6 +173,20 @@ def test_delete_item_persists_recalculated_total(self): sale.refresh_from_db() self.assertEqual(sale.total_amount, Decimal('20.00')) # 删除后总额已落库 + def test_sale_detail_does_not_rewrite_persisted_amounts(self): + sale = self._make_sale(status='COMPLETED') + sale.total_amount = Decimal('15.00') + sale.discount_amount = Decimal('0.00') + sale.final_amount = Decimal('15.00') + sale.save() + + response = self.client.get(reverse('sale_detail', args=[sale.id])) + + self.assertEqual(response.status_code, 200) + sale.refresh_from_db() + self.assertEqual(sale.total_amount, Decimal('15.00')) + self.assertEqual(sale.final_amount, Decimal('15.00')) + def test_sale_complete_page_renders_for_draft_sale(self): sale = self._make_sale(status='DRAFT') @@ -203,3 +230,19 @@ def test_sale_complete_insufficient_balance_does_not_credit_member(self): self.assertEqual(member.purchase_count, 0) self.assertEqual(member.total_spend, Decimal('0.00')) self.assertFalse(MemberTransaction.objects.filter(member=member).exists()) + + def test_sale_complete_rejects_unsupported_credit_payment(self): + sale = self._make_sale(status='DRAFT') + + response = self.client.post( + reverse('sale_complete', args=[sale.id]), + { + 'payment_method': 'credit', + 'remark': 'unsupported', + }, + ) + + self.assertRedirects(response, reverse('sale_complete', args=[sale.id])) + sale.refresh_from_db() + self.assertEqual(sale.status, 'DRAFT') + self.assertEqual(sale.payment_method, 'cash') diff --git a/inventory/tests/test_sales_balance_payment.py b/inventory/tests/test_sales_balance_payment.py index ab5edc8..2952e4e 100644 --- a/inventory/tests/test_sales_balance_payment.py +++ b/inventory/tests/test_sales_balance_payment.py @@ -97,3 +97,11 @@ def test_sale_create_balance_payment_rolls_back_when_balance_is_insufficient(sel self.assertEqual(self.member.balance, Decimal('5.00')) self.inventory.refresh_from_db() self.assertEqual(self.inventory.quantity, 10) + + def test_sale_create_rejects_unsupported_credit_payment(self): + response = self.client.post(reverse('sale_create'), self.sale_post_data('credit')) + + self.assertRedirects(response, reverse('sale_create')) + self.assertFalse(Sale.objects.exists()) + self.inventory.refresh_from_db() + self.assertEqual(self.inventory.quantity, 10) diff --git a/inventory/tests/test_views.py b/inventory/tests/test_views.py index 5ed657d..97d2acd 100644 --- a/inventory/tests/test_views.py +++ b/inventory/tests/test_views.py @@ -1,6 +1,8 @@ import json import os +import shutil import tempfile +from unittest import mock from django.core import management from django.test import TestCase, Client @@ -321,3 +323,93 @@ def test_restore_backup_flushes_records_missing_from_snapshot(self): self.assertEqual(response.status_code, 302) self.assertEqual(response['Location'], reverse('system_settings')) self.assertFalse(Product.objects.filter(pk=product.pk).exists()) + + def test_restore_backup_page_renders_with_backup_context(self): + backup_name = 'page_context' + backup_dir = os.path.join(self.backup_root, backup_name) + os.makedirs(backup_dir, exist_ok=True) + with open(os.path.join(backup_dir, 'backup_info.json'), 'w', encoding='utf-8') as backup_info: + json.dump( + { + 'name': backup_name, + 'created_at': '2026-05-30T11:00:00', + 'created_by': self.user.username, + 'includes_media': False, + }, + backup_info, + ) + + with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir): + response = self.client.get(reverse('restore_backup', args=[backup_name])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, backup_name) + + def test_restore_backup_rolls_back_database_and_media_when_media_restore_fails(self): + backup_name = 'media_failure' + backup_dir = os.path.join(self.backup_root, backup_name) + media_backup_dir = os.path.join(backup_dir, 'media') + media_root = os.path.join(self.temp_parent.name, 'media') + os.makedirs(media_backup_dir, exist_ok=True) + os.makedirs(media_root, exist_ok=True) + + with open(os.path.join(media_backup_dir, 'backup.txt'), 'w', encoding='utf-8') as backup_file: + backup_file.write('backup media') + with open(os.path.join(media_root, 'current.txt'), 'w', encoding='utf-8') as current_file: + current_file.write('current media') + + db_file = os.path.join(backup_dir, 'db.json') + with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir, MEDIA_ROOT=media_root): + management.call_command( + 'dumpdata', + '--exclude', + 'auth.permission', + '--exclude', + 'contenttypes', + '--exclude', + 'sessions.session', + '--indent', + '4', + '--output', + db_file, + verbosity=0, + ) + + with open(os.path.join(backup_dir, 'backup_info.json'), 'w', encoding='utf-8') as backup_info: + json.dump( + { + 'name': backup_name, + 'created_at': '2026-05-30T11:00:00', + 'created_by': self.user.username, + 'includes_media': True, + }, + backup_info, + ) + + category = Category.objects.create(name='恢复失败后仍应存在的分类') + product = Product.objects.create( + barcode='media-restore-failure-product', + name='恢复失败后仍应存在的商品', + category=category, + price=Decimal('10.00'), + cost=Decimal('5.00'), + ) + + original_copytree = shutil.copytree + + def fail_when_replacing_media(src, dst, *args, **kwargs): + if dst == media_root: + raise OSError('simulated media restore failure') + return original_copytree(src, dst, *args, **kwargs) + + with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir, MEDIA_ROOT=media_root): + with mock.patch('inventory.views.system.backup.shutil.copytree', side_effect=fail_when_replacing_media): + response = self.client.post( + reverse('restore_backup', args=[backup_name]), + {'confirm_restore': 'on', 'restore_media': 'on'}, + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(Product.objects.filter(pk=product.pk).exists()) + self.assertTrue(os.path.exists(os.path.join(media_root, 'current.txt'))) + self.assertFalse(os.path.exists(os.path.join(media_root, 'backup.txt'))) diff --git a/inventory/views/sales.py b/inventory/views/sales.py index 0fc02a4..aed4e54 100644 --- a/inventory/views/sales.py +++ b/inventory/views/sales.py @@ -7,7 +7,7 @@ from django.utils import timezone from datetime import datetime, timedelta, date from decimal import Decimal, InvalidOperation -from django.http import JsonResponse, HttpResponse +from django.http import JsonResponse, HttpResponse, HttpResponseNotAllowed from django.template.loader import render_to_string from django.core.paginator import Paginator from django.conf import settings @@ -19,6 +19,20 @@ from inventory.services import member_service from inventory.utils.query_utils import paginate_queryset + +VALID_PAYMENT_METHODS = {method for method, _label in Sale.PAYMENT_METHODS} + + +def normalize_sale_payment_method(payment_method, default='cash'): + """Normalize legacy aliases and reject payment methods without backend handling.""" + normalized = payment_method or default + if normalized == 'account': + normalized = 'balance' + if normalized not in VALID_PAYMENT_METHODS: + raise ValueError('不支持的支付方式') + return normalized + + @login_required def sale_list(request): """销售单列表视图""" @@ -80,31 +94,6 @@ def sale_detail(request, sale_id): sale = get_object_or_404(Sale, pk=sale_id) items = SaleItem.objects.filter(sale=sale).select_related('product') - # 确保销售单金额与商品项总和一致 - items_total = sum(item.subtotal for item in items) - if items_total > 0 and (sale.total_amount == 0 or abs(sale.total_amount - items_total) > 1): - print(f"警告: 销售单金额({sale.total_amount})与商品项总和({items_total})不一致,正在修复") - # 更新销售单金额 - discount_rate = Decimal('1.0') - if sale.member and sale.member.level and sale.member.level.discount: - try: - discount_rate = Decimal(str(sale.member.level.discount)) - except: - discount_rate = Decimal('1.0') - - discount_amount = items_total * (Decimal('1.0') - discount_rate) - final_amount = items_total - discount_amount - - # 使用原始SQL直接更新数据库 - with connection.cursor() as cursor: - cursor.execute( - "UPDATE inventory_sale SET total_amount = %s, discount_amount = %s, final_amount = %s WHERE id = %s", - [items_total, discount_amount, final_amount, sale.id] - ) - - # 重新加载销售单数据 - sale = get_object_or_404(Sale, pk=sale_id) - context = { 'sale': sale, 'items': items, @@ -331,11 +320,8 @@ def sale_create(request): # 最终安全检查,确保总金额大于0 if total_amount <= 0 and valid_products_data: - print("警告:计算的总金额仍然为0或负数,使用固定价格作为最后的保障") - # 使用855.33作为固定价格,这只是一个保底措施 - total_amount = Decimal('855.33') - discount_amount = Decimal('0.00') - final_amount = total_amount + messages.error(request, '销售单创建失败,商品金额无效。') + return redirect('sale_create') form = SaleForm(request.POST) if form.is_valid(): @@ -357,11 +343,13 @@ def sale_create(request): except Member.DoesNotExist: pass - # 设置支付方式。旧前端曾提交 account,后端统一按账户余额处理。 - payment_method = request.POST.get('payment_method', 'cash') - if payment_method == 'account': - payment_method = 'balance' - sale.payment_method = payment_method + try: + sale.payment_method = normalize_sale_payment_method( + request.POST.get('payment_method', 'cash') + ) + except ValueError as e: + messages.error(request, str(e)) + return redirect('sale_create') # 收银台是一次性下单并结算,直接标记为已完成 sale.status = 'COMPLETED' @@ -646,9 +634,10 @@ def sale_complete(request, sale_id): sale.final_amount = sale.total_amount - sale.discount_amount sale.points_earned = int(sale.final_amount) - payment_method = request.POST.get('payment_method') or sale.payment_method - if payment_method == 'account': - payment_method = 'balance' + payment_method = normalize_sale_payment_method( + request.POST.get('payment_method'), + default=sale.payment_method, + ) sale.payment_method = payment_method balance_amount = Decimal('0.00') @@ -781,41 +770,44 @@ def sale_cancel(request, sale_id): @login_required def sale_delete_item(request, sale_id, item_id): """删除销售单商品视图""" - sale = get_object_or_404(Sale, id=sale_id) - item = get_object_or_404(SaleItem, id=item_id, sale=sale) - - # 检查销售单状态 - if sale.status != 'DRAFT': - messages.error(request, '只有未完成的销售单可以修改商品') - return redirect('sale_detail', sale_id=sale.id) - - # 恢复库存 - inventory = Inventory.objects.get(product=item.product) - inventory.quantity += item.quantity - inventory.save() - - # 创建入库交易记录 - InventoryTransaction.objects.create( - product=item.product, - transaction_type='IN', - quantity=item.quantity, - operator=request.user, - notes=f'从销售单 #{sale.id} 中删除商品,恢复库存' - ) - - # 记录操作日志 - OperationLog.objects.create( - operator=request.user, - operation_type='SALE', - details=f'从销售单 #{sale.id} 中删除商品 {item.product.name}', - related_object_id=sale.id, - related_content_type=ContentType.objects.get_for_model(Sale) - ) - - # 删除商品并更新销售单总额 - item.delete() - sale.update_total_amount() - sale.save() + if request.method != 'POST': + return HttpResponseNotAllowed(['POST']) + + with transaction.atomic(): + sale = get_object_or_404(Sale.objects.select_for_update(), id=sale_id) + if sale.status != 'DRAFT': + messages.error(request, '只有未完成的销售单可以修改商品') + return redirect('sale_detail', sale_id=sale.id) + + item = get_object_or_404( + SaleItem.objects.select_for_update().select_related('product'), + id=item_id, + sale=sale, + ) + + inventory = Inventory.objects.select_for_update().get(product=item.product) + inventory.quantity += item.quantity + inventory.save() + + InventoryTransaction.objects.create( + product=item.product, + transaction_type='IN', + quantity=item.quantity, + operator=request.user, + notes=f'从销售单 #{sale.id} 中删除商品,恢复库存' + ) + + OperationLog.objects.create( + operator=request.user, + operation_type='SALE', + details=f'从销售单 #{sale.id} 中删除商品 {item.product.name}', + related_object_id=sale.id, + related_content_type=ContentType.objects.get_for_model(Sale) + ) + + item.delete() + sale.update_total_amount() + sale.save() messages.success(request, '商品已从销售单中删除') return redirect('sale_item_create', sale_id=sale.id) diff --git a/inventory/views/system/backup.py b/inventory/views/system/backup.py index 39a2423..d269e18 100644 --- a/inventory/views/system/backup.py +++ b/inventory/views/system/backup.py @@ -20,6 +20,7 @@ import logging import re import zipfile +import tempfile from datetime import datetime from inventory.permissions.decorators import permission_required @@ -64,6 +65,31 @@ def get_dir_size_display(dir_path): else: return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" + +def get_backup_context(backup_name, backup_dir, backup_info): + """Build the context shape expected by backup templates.""" + created_at = None + created_at_value = backup_info.get('created_at') + if created_at_value: + try: + created_at = datetime.fromisoformat(created_at_value) + except (TypeError, ValueError): + logger.warning("备份创建时间格式无效: %s", created_at_value) + + backup = { + 'name': backup_name, + 'created_at': created_at, + 'created_by': backup_info.get('created_by', '未知'), + 'size': get_dir_size_display(backup_dir), + 'includes_media': backup_info.get('includes_media', False), + 'description': backup_info.get('description', ''), + } + return { + 'backup': backup, + 'backup_name': backup_name, + 'backup_info': backup_info, + } + @login_required @permission_required('inventory.can_manage_backup') def backup_list(request): @@ -209,55 +235,63 @@ def restore_backup(request, backup_name): if os.path.exists(backup_info_file): with open(backup_info_file, 'r', encoding='utf-8') as f: backup_info = json.load(f) + context = get_backup_context(backup_name, backup_dir, backup_info) if request.method == 'POST': # 确认恢复 - confirmed = request.POST.get('confirm') == 'on' + confirmed = ( + request.POST.get('confirm_restore') == 'on' + or request.POST.get('confirm') == 'on' + ) if not confirmed: messages.error(request, "请确认您要恢复备份") - return render(request, 'inventory/system/restore_backup.html', { - 'backup_name': backup_name, - 'backup_info': backup_info - }) + return render(request, 'inventory/system/restore_backup.html', context) + temp_restore_dir = None + media_stage_dir = None + old_media_dir = None try: # 恢复数据库 db_file = os.path.join(backup_dir, 'db.json') if not os.path.exists(db_file): messages.error(request, f"备份文件 {db_file} 不存在") return redirect('backup_list') - + + restore_media = request.POST.get('restore_media') == 'on' + media_dir = os.path.join(backup_dir, 'media') + if restore_media and backup_info.get('includes_media', False): + if os.path.exists(media_dir): + os.makedirs(settings.TEMP_DIR, exist_ok=True) + temp_restore_dir = tempfile.mkdtemp( + prefix=f"restore_{backup_name}_", + dir=settings.TEMP_DIR, + ) + media_stage_dir = os.path.join(temp_restore_dir, 'media') + shutil.copytree(media_dir, media_stage_dir) + # 先清空数据库再加载快照;loaddata 只会 upsert,不能删除备份后新增的数据。 + # 媒体文件先复制到临时目录,再在事务内替换;替换失败会回滚数据库并恢复旧媒体。 with transaction.atomic(): management.call_command('flush', '--noinput', verbosity=0) management.call_command('loaddata', db_file, verbosity=0) - - # 恢复媒体文件 - restore_media = request.POST.get('restore_media') == 'on' - if restore_media and backup_info.get('includes_media', False): - media_dir = os.path.join(backup_dir, 'media') - if os.path.exists(media_dir): - # 清空现有媒体目录 + + if media_stage_dir: + media_parent = os.path.dirname(settings.MEDIA_ROOT) + if media_parent: + os.makedirs(media_parent, exist_ok=True) + + old_media_dir = os.path.join(temp_restore_dir, 'old_media') if os.path.exists(settings.MEDIA_ROOT): - for item in os.listdir(settings.MEDIA_ROOT): - item_path = os.path.join(settings.MEDIA_ROOT, item) - if os.path.isdir(item_path): - shutil.rmtree(item_path) - else: - os.remove(item_path) - - # 复制备份中的媒体文件 - for item in os.listdir(media_dir): - src_path = os.path.join(media_dir, item) - dst_path = os.path.join(settings.MEDIA_ROOT, item) - if os.path.isdir(src_path): - if os.path.exists(dst_path): - shutil.rmtree(dst_path) - shutil.copytree(src_path, dst_path) - else: - if os.path.exists(dst_path): - os.remove(dst_path) - shutil.copy2(src_path, dst_path) + shutil.move(settings.MEDIA_ROOT, old_media_dir) + + try: + shutil.copytree(media_stage_dir, settings.MEDIA_ROOT) + except Exception: + if os.path.exists(settings.MEDIA_ROOT): + shutil.rmtree(settings.MEDIA_ROOT) + if old_media_dir and os.path.exists(old_media_dir): + shutil.move(old_media_dir, settings.MEDIA_ROOT) + raise # 恢复快照后,执行恢复的用户可能已不存在于当前数据库。 restored_user = get_user_model().objects.filter(pk=request.user.pk).first() @@ -279,15 +313,12 @@ def restore_backup(request, backup_name): except Exception as e: messages.error(request, f"恢复备份失败: {str(e)}") logger.error(f"恢复备份失败: {str(e)}") - return render(request, 'inventory/system/restore_backup.html', { - 'backup_name': backup_name, - 'backup_info': backup_info - }) + return render(request, 'inventory/system/restore_backup.html', context) + finally: + if temp_restore_dir and os.path.exists(temp_restore_dir): + shutil.rmtree(temp_restore_dir, ignore_errors=True) - return render(request, 'inventory/system/restore_backup.html', { - 'backup_name': backup_name, - 'backup_info': backup_info - }) + return render(request, 'inventory/system/restore_backup.html', context) @login_required @permission_required('inventory.can_manage_backup')