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..d34513e 100644 --- a/inventory/templates/inventory/sale_item_form.html +++ b/inventory/templates/inventory/sale_item_form.html @@ -483,9 +483,12 @@

添加销售商品

{{ item.actual_price }} {{ item.subtotal }} - - - +
+ {% csrf_token %} + +
{% endfor %} diff --git a/inventory/templates/inventory/system/delete_backup.html b/inventory/templates/inventory/system/delete_backup.html new file mode 100644 index 0000000..ecc559d --- /dev/null +++ b/inventory/templates/inventory/system/delete_backup.html @@ -0,0 +1,35 @@ +{% 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..0ac44ef 100644 --- a/inventory/tests/test_sale_status.py +++ b/inventory/tests/test_sale_status.py @@ -160,6 +160,47 @@ def test_delete_item_persists_recalculated_total(self): sale.refresh_from_db() self.assertEqual(sale.total_amount, Decimal('20.00')) # 删除后总额已落库 + def test_get_delete_item_is_not_allowed(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_sale_detail_get_does_not_rewrite_persisted_amounts(self): + sale = self._make_sale(status='COMPLETED') + Sale.objects.filter(pk=sale.pk).update( + total_amount=Decimal('99.00'), + discount_amount=Decimal('0.00'), + final_amount=Decimal('99.00'), + ) + + 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('99.00')) + self.assertEqual(sale.final_amount, Decimal('99.00')) + + def test_sale_complete_rejects_unsupported_payment_method(self): + sale = self._make_sale(status='DRAFT') + + response = self.client.post( + reverse('sale_complete', args=[sale.id]), + {'payment_method': 'credit'}, + ) + + self.assertRedirects(response, reverse('sale_complete', args=[sale.id])) + sale.refresh_from_db() + self.assertEqual(sale.status, 'DRAFT') + self.assertEqual(sale.payment_method, 'cash') + def test_sale_complete_page_renders_for_draft_sale(self): sale = self._make_sale(status='DRAFT') diff --git a/inventory/tests/test_sales_balance_payment.py b/inventory/tests/test_sales_balance_payment.py index ab5edc8..7a66789 100644 --- a/inventory/tests/test_sales_balance_payment.py +++ b/inventory/tests/test_sales_balance_payment.py @@ -97,3 +97,13 @@ 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_payment_method(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) + self.member.refresh_from_db() + self.assertEqual(self.member.balance, Decimal('100.00')) diff --git a/inventory/tests/test_views.py b/inventory/tests/test_views.py index 5ed657d..390e9f8 100644 --- a/inventory/tests/test_views.py +++ b/inventory/tests/test_views.py @@ -5,6 +5,7 @@ from django.core import management from django.test import TestCase, Client from django.urls import reverse +from django.contrib.admin.models import LogEntry from django.contrib.auth.models import User, Permission, Group from decimal import Decimal @@ -258,6 +259,21 @@ def setUp(self): os.makedirs(self.backup_root, exist_ok=True) os.makedirs(self.temp_dir, exist_ok=True) + def _write_backup_info(self, backup_name, includes_media=False): + 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': includes_media, + }, + backup_info, + ) + return backup_dir + def test_delete_backup_rejects_parent_directory_traversal(self): sentinel_path = os.path.join(self.temp_parent.name, 'keep.txt') with open(sentinel_path, 'w', encoding='utf-8') as sentinel: @@ -270,10 +286,31 @@ def test_delete_backup_rejects_parent_directory_traversal(self): self.assertTrue(os.path.exists(sentinel_path)) self.assertTrue(os.path.isdir(self.backup_root)) + def test_delete_backup_confirmation_page_renders(self): + backup_name = 'snapshot' + self._write_backup_info(backup_name) + + with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir): + response = self.client.get(reverse('delete_backup', args=[backup_name])) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'inventory/system/delete_backup.html') + self.assertContains(response, backup_name) + + def test_restore_backup_page_renders_with_backup_context(self): + backup_name = 'snapshot' + self._write_backup_info(backup_name) + + 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.assertTemplateUsed(response, 'inventory/system/restore_backup.html') + self.assertContains(response, backup_name) + def test_restore_backup_flushes_records_missing_from_snapshot(self): backup_name = 'snapshot' - backup_dir = os.path.join(self.backup_root, backup_name) - os.makedirs(backup_dir, exist_ok=True) + backup_dir = self._write_backup_info(backup_name) db_file = os.path.join(backup_dir, 'db.json') with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir): @@ -292,17 +329,6 @@ def test_restore_backup_flushes_records_missing_from_snapshot(self): 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': False, - }, - backup_info, - ) - category = Category.objects.create(name='备份后分类') product = Product.objects.create( barcode='post-backup-product', @@ -315,9 +341,71 @@ def test_restore_backup_flushes_records_missing_from_snapshot(self): with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir): response = self.client.post( reverse('restore_backup', args=[backup_name]), - {'confirm': 'on'}, + {'confirm_restore': 'on'}, ) self.assertEqual(response.status_code, 302) self.assertEqual(response['Location'], reverse('system_settings')) self.assertFalse(Product.objects.filter(pk=product.pk).exists()) + + +class LogFileViewTest(TestCase): + """系统日志文件操作回归测试""" + + def setUp(self): + self.client = Client() + self.user = User.objects.create_superuser( + username='log-admin', + password='log-pass', + email='log@example.com', + ) + self.client.force_login(self.user) + + self.log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs') + os.makedirs(self.log_dir, exist_ok=True) + self.addCleanup(self._cleanup_test_logs) + + def _cleanup_test_logs(self): + for file_name in ('download-test.log', 'delete-test.log'): + file_path = os.path.join(self.log_dir, file_name) + if os.path.exists(file_path): + os.remove(file_path) + + def _write_log_file(self, file_name): + file_path = os.path.join(self.log_dir, file_name) + with open(file_path, 'w', encoding='utf-8') as log_file: + log_file.write('test log line\n') + return file_path + + def test_download_log_file_records_nullable_content_type(self): + self._write_log_file('download-test.log') + + response = self.client.get(reverse('download_log_file', args=['download-test.log'])) + + self.assertEqual(response.status_code, 200) + self.assertIn('download-test.log', response['Content-Disposition']) + self.assertTrue( + LogEntry.objects.filter( + user=self.user, + object_id='download-test.log', + content_type__isnull=True, + ).exists() + ) + + def test_delete_log_file_records_nullable_content_type(self): + file_path = self._write_log_file('delete-test.log') + + response = self.client.post( + reverse('delete_log_file', args=['delete-test.log']), + {'confirm': 'on'}, + ) + + self.assertRedirects(response, reverse('log_list')) + self.assertFalse(os.path.exists(file_path)) + self.assertTrue( + LogEntry.objects.filter( + user=self.user, + object_id='delete-test.log', + content_type__isnull=True, + ).exists() + ) diff --git a/inventory/views/sales.py b/inventory/views/sales.py index 0fc02a4..f5a8059 100644 --- a/inventory/views/sales.py +++ b/inventory/views/sales.py @@ -13,12 +13,25 @@ from django.conf import settings from django.utils.safestring import mark_safe from django.urls import reverse +from django.views.decorators.http import require_POST from inventory.models import Sale, SaleItem, Inventory, InventoryTransaction, Member, MemberTransaction, OperationLog, Product, Category, Supplier, MemberLevel from inventory.forms import SaleForm, SaleItemForm from inventory.services import member_service from inventory.utils.query_utils import paginate_queryset + +def normalize_sale_payment_method(payment_method): + """Normalize legacy aliases and reject methods that have no settlement logic.""" + method = (payment_method or 'cash').strip() + if method == 'account': + method = 'balance' + + valid_methods = {value for value, _label in Sale.PAYMENT_METHODS} + if method not in valid_methods: + raise ValueError('不支持的支付方式') + return method + @login_required def sale_list(request): """销售单列表视图""" @@ -80,31 +93,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, @@ -358,10 +346,11 @@ def sale_create(request): 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 +635,7 @@ 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') or sale.payment_method) sale.payment_method = payment_method balance_amount = Decimal('0.00') @@ -779,43 +766,48 @@ def sale_cancel(request, sale_id): return render(request, 'inventory/sale_cancel.html', {'sale': sale}) @login_required +@require_POST 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() + 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..3aa7044 100644 --- a/inventory/views/system/backup.py +++ b/inventory/views/system/backup.py @@ -64,6 +64,28 @@ def get_dir_size_display(dir_path): else: return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" + +def build_backup_context(backup_name, backup_dir, backup_info): + """Build the template context shape expected by backup pages.""" + created_at = None + if backup_info.get('created_at'): + try: + created_at = datetime.fromisoformat(backup_info['created_at']) + except (TypeError, ValueError): + created_at = None + + return { + '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), + }, + 'backup_name': backup_name, + 'backup_info': backup_info, + } + @login_required @permission_required('inventory.can_manage_backup') def backup_list(request): @@ -209,16 +231,14 @@ 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 = build_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) try: # 恢复数据库 @@ -279,15 +299,9 @@ 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) - 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') diff --git a/inventory/views/system/log.py b/inventory/views/system/log.py index bfba22b..88aea0b 100644 --- a/inventory/views/system/log.py +++ b/inventory/views/system/log.py @@ -248,7 +248,7 @@ def download_log_file(request, file_name): LogEntry.objects.create( user=request.user, action_flag=1, - content_type_id=0, + content_type_id=None, object_id=file_name, object_repr=f'下载日志: {file_name}', change_message=f'下载了日志文件 {file_name}' @@ -294,7 +294,7 @@ def delete_log_file(request, file_name): LogEntry.objects.create( user=request.user, action_flag=3, - content_type_id=0, + content_type_id=None, object_id=file_name, object_repr=f'删除日志: {file_name}', change_message=f'删除了日志文件 {file_name}'