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: 2 additions & 2 deletions inventory/templates/inventory/sale_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -1346,9 +1346,9 @@ <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">
<button type="button" class="payment-btn" data-payment="other">
<i class="bi bi-journal-text"></i>
{% if request.LANGUAGE_CODE == 'en' %}Credit{% else %}挂账{% endif %}
{% if request.LANGUAGE_CODE == 'en' %}Other{% else %}其他{% endif %}
</button>
</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
10 changes: 7 additions & 3 deletions inventory/templates/inventory/system/backup_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,11 @@ <h5 class="modal-title" id="deleteBackupModalLabel">删除备份</h5>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<a href="#" id="confirm-delete-backup" class="btn btn-danger">确认删除</a>
<form method="post" id="delete-backup-form" class="d-inline">
{% csrf_token %}
<input type="hidden" name="confirm" value="on">
<button type="submit" class="btn btn-danger">确认删除</button>
</form>
</div>
</div>
</div>
Expand All @@ -160,13 +164,13 @@ <h5 class="modal-title" id="deleteBackupModalLabel">删除备份</h5>
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();
});
});
Expand Down
27 changes: 27 additions & 0 deletions inventory/templates/inventory/system/delete_backup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{% extends 'inventory/base.html' %}

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

{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-danger text-white">
<h5 class="card-title mb-0">删除备份</h5>
</div>
<div class="card-body">
<p>确定要删除备份 <strong>{{ backup_name }}</strong> 吗?</p>
<p class="text-danger">此操作不可恢复。</p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="confirm" value="on">
<div class="d-flex justify-content-between">
<a href="{% url 'backup_list' %}" class="btn btn-outline-secondary">取消</a>
<button type="submit" class="btn btn-danger">确认删除</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
43 changes: 43 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_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(
Expand All @@ -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')

Expand Down Expand Up @@ -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')
8 changes: 8 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,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)
92 changes: 92 additions & 0 deletions inventory/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')))
Loading