Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions inventory/templates/inventory/sale_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -1346,10 +1346,6 @@ <h5 class="payment-title mb-3">{% if request.LANGUAGE_CODE == 'en' %}Payment Met
<i class="bi bi-wallet2"></i>
{% if request.LANGUAGE_CODE == 'en' %}Balance{% else %}余额{% endif %}
</button>
<button type="button" class="payment-btn" data-payment="credit">
<i class="bi bi-journal-text"></i>
{% if request.LANGUAGE_CODE == 'en' %}Credit{% else %}挂账{% endif %}
</button>
</div>
</div>
</div>
Expand Down
11 changes: 7 additions & 4 deletions inventory/templates/inventory/sale_item_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -476,16 +476,19 @@ <h4 class="card-title mb-0">添加销售商品</h4>
</tr>
</thead>
<tbody id="sale-items">
{% for item in sale.saleitems.all %}
{% for item in items %}
<tr>
<td>{{ item.product.name }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.actual_price }}</td>
<td>{{ item.subtotal }}</td>
<td>
<a href="{% url 'sale_item_delete' sale.id item.id %}" class="btn btn-sm btn-outline-danger" title="删除">
<i class="bi bi-trash"></i>
</a>
<form method="post" action="{% url 'sale_item_delete' sale.id item.id %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" title="删除">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
Expand Down
40 changes: 40 additions & 0 deletions inventory/templates/inventory/system/delete_backup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{% extends 'inventory/base.html' %}

{% block title %}删除备份 - {{ block.super }}{% endblock %}

{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h5 class="card-title mb-0">
<i class="bi bi-exclamation-triangle-fill me-2"></i> 删除备份
</h5>
</div>
<div class="card-body">
<p>确定要删除备份 <strong>{{ backup_name }}</strong> 吗?</p>
<p class="text-danger">此操作不可恢复。</p>

<form method="post" action="{% url 'delete_backup' backup_name %}">
{% csrf_token %}
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="confirm" name="confirm" required>
<label class="form-check-label" for="confirm">
我确认要删除此备份
</label>
</div>

<div class="d-flex justify-content-between">
<a href="{% url 'backup_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> 返回
</a>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i> 确认删除
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
44 changes: 44 additions & 0 deletions inventory/tests/test_sale_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_sale_item(self):
sale = self._make_sale(status='DRAFT')
item = sale.items.get()
self.inventory.refresh_from_db()
quantity_before = self.inventory.quantity

response = self.client.get(reverse('sale_item_delete', args=[sale.id, item.id]))

self.assertRedirects(response, reverse('sale_item_create', args=[sale.id]))
self.assertTrue(SaleItem.objects.filter(pk=item.pk).exists())
self.inventory.refresh_from_db()
self.assertEqual(self.inventory.quantity, quantity_before)

def test_delete_item_persists_recalculated_total(self):
sale = self._make_sale(status='DRAFT')
extra = SaleItem.objects.create(
Expand All @@ -160,6 +173,21 @@ def test_delete_item_persists_recalculated_total(self):
sale.refresh_from_db()
self.assertEqual(sale.total_amount, Decimal('20.00')) # 删除后总额已落库

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('5.00'),
discount_amount=Decimal('0.00'),
final_amount=Decimal('5.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('5.00'))
self.assertEqual(sale.final_amount, Decimal('5.00'))

def test_sale_complete_page_renders_for_draft_sale(self):
sale = self._make_sale(status='DRAFT')

Expand Down Expand Up @@ -203,3 +231,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_payment_method(self):
sale = self._make_sale(status='DRAFT')

response = self.client.post(
reverse('sale_complete', args=[sale.id]),
{
'payment_method': 'credit',
'remark': 'unsupported credit payment',
},
)

self.assertRedirects(response, reverse('sale_complete', args=[sale.id]))
sale.refresh_from_db()
self.assertEqual(sale.status, 'DRAFT')
self.assertEqual(sale.payment_method, 'cash')
10 changes: 10 additions & 0 deletions inventory/tests/test_sales_balance_payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_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)
self.member.refresh_from_db()
self.assertEqual(self.member.balance, Decimal('100.00'))
202 changes: 202 additions & 0 deletions inventory/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import json
import os
import tempfile
from unittest import mock

from django.contrib.admin.models import LogEntry
from django.core import management
from django.test import TestCase, Client
from django.urls import reverse
Expand Down Expand Up @@ -255,8 +257,10 @@ def setUp(self):
self.addCleanup(self.temp_parent.cleanup)
self.backup_root = os.path.join(self.temp_parent.name, 'backups')
self.temp_dir = os.path.join(self.temp_parent.name, 'temp')
self.media_root = os.path.join(self.temp_parent.name, 'media')
os.makedirs(self.backup_root, exist_ok=True)
os.makedirs(self.temp_dir, exist_ok=True)
os.makedirs(self.media_root, exist_ok=True)

def test_delete_backup_rejects_parent_directory_traversal(self):
sentinel_path = os.path.join(self.temp_parent.name, 'keep.txt')
Expand Down Expand Up @@ -321,3 +325,201 @@ 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_accepts_template_confirm_field(self):
backup_name = 'template-confirm'
backup_dir = os.path.join(self.backup_root, backup_name)
os.makedirs(backup_dir, exist_ok=True)
db_file = os.path.join(backup_dir, 'db.json')

with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir, MEDIA_ROOT=self.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': False,
},
backup_info,
)

category = Category.objects.create(name='模板确认后分类')
product = Product.objects.create(
barcode='template-confirm-product',
name='模板确认后商品',
category=category,
price=Decimal('10.00'),
cost=Decimal('5.00'),
)

with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir, MEDIA_ROOT=self.media_root):
response = self.client.post(
reverse('restore_backup', args=[backup_name]),
{'confirm_restore': 'on'},
)

self.assertEqual(response.status_code, 302)
self.assertFalse(Product.objects.filter(pk=product.pk).exists())

def test_restore_backup_page_renders_template_context(self):
backup_name = 'renderable'
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, MEDIA_ROOT=self.media_root):
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_delete_backup_confirmation_page_renders(self):
backup_name = 'delete-renderable'
os.makedirs(os.path.join(self.backup_root, backup_name), exist_ok=True)

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_rolls_back_database_and_media_when_media_replace_fails(self):
backup_name = 'media-rollback'
backup_dir = os.path.join(self.backup_root, backup_name)
backup_media_dir = os.path.join(backup_dir, 'media')
os.makedirs(backup_media_dir, exist_ok=True)
db_file = os.path.join(backup_dir, 'db.json')

with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir, MEDIA_ROOT=self.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_media_dir, 'backup-file.txt'), 'w', encoding='utf-8') as media_file:
media_file.write('backup media')
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-rollback-product',
name='恢复失败后应保留商品',
category=category,
price=Decimal('10.00'),
cost=Decimal('5.00'),
)
current_media_file = os.path.join(self.media_root, 'current-file.txt')
with open(current_media_file, 'w', encoding='utf-8') as media_file:
media_file.write('current media')

with self.settings(BACKUP_ROOT=self.backup_root, TEMP_DIR=self.temp_dir, MEDIA_ROOT=self.media_root):
with mock.patch(
'inventory.views.system.backup.replace_media_root',
side_effect=RuntimeError('media copy failed'),
):
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(current_media_file))


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_logs)

def _cleanup_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 test_download_log_file_uses_nullable_content_type(self):
file_name = 'download-test.log'
with open(os.path.join(self.log_dir, file_name), 'w', encoding='utf-8') as log_file:
log_file.write('download me')

response = self.client.get(reverse('download_log_file', args=[file_name]))

self.assertEqual(response.status_code, 200)
self.assertTrue(
LogEntry.objects.filter(
object_id=file_name,
content_type__isnull=True,
).exists()
)

def test_delete_log_file_uses_nullable_content_type(self):
file_name = 'delete-test.log'
file_path = os.path.join(self.log_dir, file_name)
with open(file_path, 'w', encoding='utf-8') as log_file:
log_file.write('delete me')

response = self.client.post(reverse('delete_log_file', args=[file_name]), {'confirm': 'on'})

self.assertRedirects(response, reverse('log_list'))
self.assertFalse(os.path.exists(file_path))
self.assertTrue(
LogEntry.objects.filter(
object_id=file_name,
content_type__isnull=True,
).exists()
)
Loading