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">支付方式</h5>
<i class="bi bi-wallet2"></i>
余额
</button>
<button type="button" class="payment-btn" data-payment="credit">
<i class="bi bi-journal-text"></i>
挂账
</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
30 changes: 30 additions & 0 deletions inventory/templates/inventory/system/delete_backup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{% extends 'inventory/base.html' %}

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

{% block content %}
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<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 mb-4">此操作不可恢复。</p>

<form method="post">
{% csrf_token %}
<input type="hidden" name="confirm" value="on">
<div class="d-flex justify-content-end gap-2">
<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 %}
40 changes: 40 additions & 0 deletions inventory/tests/test_sale_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,32 @@ def test_cannot_delete_item_from_cancelled_sale(self):
self.inventory.refresh_from_db()
self.assertEqual(self.inventory.quantity, restored_quantity)

def test_cannot_delete_item_from_completed_sale(self):
sale = self._make_sale(status='COMPLETED')
item = sale.items.get()
self.inventory.refresh_from_db()
before = self.inventory.quantity

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

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

def test_delete_item_requires_post(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 +186,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_historical_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
9 changes: 9 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,12 @@ 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.assertFalse(MemberTransaction.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_backup_context(self):
backup_name = 'pagecontext'
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': True,
},
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_media_replace_failure_restores_current_media_and_db(self):
backup_name = 'mediafail'
backup_dir = os.path.join(self.backup_root, backup_name)
backup_media_dir = os.path.join(backup_dir, 'media')
media_root = os.path.join(self.temp_parent.name, 'media')
os.makedirs(backup_media_dir, exist_ok=True)
os.makedirs(media_root, exist_ok=True)
db_file = os.path.join(backup_dir, 'db.json')

with open(os.path.join(media_root, 'current.txt'), 'w', encoding='utf-8') as current:
current.write('current media')
with open(os.path.join(backup_media_dir, 'replacement.txt'), 'w', encoding='utf-8') as replacement:
replacement.write('backup media')

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='restore-failure-product',
name='恢复失败后仍应存在商品',
category=category,
price=Decimal('10.00'),
cost=Decimal('5.00'),
)

real_move = shutil.move

def fail_staged_media_move(src, dst, *args, **kwargs):
if os.path.basename(src) == 'media' and os.path.abspath(dst) == os.path.abspath(media_root):
raise OSError('cannot replace media')
return real_move(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.move', side_effect=fail_staged_media_move):
response = self.client.post(
reverse('restore_backup', args=[backup_name]),
{'confirm': '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, 'replacement.txt')))
115 changes: 49 additions & 66 deletions inventory/views/sales.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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
Expand Down Expand Up @@ -80,31 +81,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,
Expand Down Expand Up @@ -331,11 +307,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, '销售单创建失败,商品金额必须大于0。')
return redirect('sale_create')

form = SaleForm(request.POST)
if form.is_valid():
Expand All @@ -361,6 +334,10 @@ def sale_create(request):
payment_method = request.POST.get('payment_method', 'cash')
if payment_method == 'account':
payment_method = 'balance'
allowed_payment_methods = {'cash', 'wechat', 'alipay', 'card', 'balance', 'other'}
if payment_method not in allowed_payment_methods:
messages.error(request, '销售单创建失败,不支持的支付方式。')
return redirect('sale_create')
sale.payment_method = payment_method

# 收银台是一次性下单并结算,直接标记为已完成
Expand Down Expand Up @@ -649,6 +626,8 @@ def sale_complete(request, sale_id):
payment_method = request.POST.get('payment_method') or sale.payment_method
if payment_method == 'account':
payment_method = 'balance'
if payment_method not in {value for value, _ in Sale.PAYMENT_METHODS}:
raise ValueError('不支持的支付方式')
sale.payment_method = payment_method

balance_amount = Decimal('0.00')
Expand Down Expand Up @@ -779,46 +758,50 @@ 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()
try:
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()
except Inventory.DoesNotExist:
messages.error(request, '找不到商品库存记录,未删除销售明细')
return redirect('sale_item_create', sale_id=sale_id)

messages.success(request, '商品已从销售单中删除')
return redirect('sale_item_create', sale_id=sale.id)
return redirect('sale_item_create', sale_id=sale_id)

@login_required
def member_purchases(request):
Expand Down
Loading