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
45 changes: 45 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_delete_item_is_rejected_without_restoring_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,22 @@ 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_recalculate_persisted_amounts(self):
sale = self._make_sale(status='COMPLETED')
Sale.objects.filter(pk=sale.pk).update(
total_amount=Decimal('100.00'),
discount_amount=Decimal('5.00'),
final_amount=Decimal('95.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('100.00'))
self.assertEqual(sale.discount_amount, Decimal('5.00'))
self.assertEqual(sale.final_amount, Decimal('95.00'))

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

Expand Down Expand Up @@ -203,3 +232,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',
},
)

self.assertRedirects(response, reverse('sale_complete', args=[sale.id]))
sale.refresh_from_db()
self.assertEqual(sale.status, 'DRAFT')
self.assertEqual(sale.payment_method, 'cash')
21 changes: 21 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,24 @@ 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.member.refresh_from_db()
self.assertEqual(self.member.balance, Decimal('100.00'))
self.inventory.refresh_from_db()
self.assertEqual(self.inventory.quantity, 10)

def test_sale_create_rejects_zero_final_amount(self):
self.level.discount = Decimal('0.00')
self.level.save(update_fields=['discount'])

response = self.client.post(reverse('sale_create'), self.sale_post_data('cash'))

self.assertRedirects(response, reverse('sale_create'))
self.assertFalse(Sale.objects.exists())
self.inventory.refresh_from_db()
self.assertEqual(self.inventory.quantity, 10)
61 changes: 61 additions & 0 deletions inventory/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import os
import tempfile

from django.conf import settings
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 @@ -321,3 +323,62 @@ 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())


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)
os.makedirs(settings.LOG_DIR, exist_ok=True)

def _write_log_file(self, file_name, content='log line\n'):
file_path = os.path.join(settings.LOG_DIR, file_name)
with open(file_path, 'w', encoding='utf-8') as log_file:
log_file.write(content)
self.addCleanup(lambda: os.path.exists(file_path) and os.remove(file_path))
return file_path

def test_download_log_file_records_nullable_content_type(self):
file_name = 'download-test.log'
file_path = self._write_log_file(file_name)

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

self.assertEqual(response.status_code, 200)
response.close()
self.assertTrue(os.path.exists(file_path))
self.assertTrue(
LogEntry.objects.filter(
user=self.user,
object_id=file_name,
content_type__isnull=True,
object_repr=f'下载日志: {file_name}',
).exists()
)

def test_delete_log_file_records_audit_and_removes_file(self):
file_name = 'delete-test.log'
file_path = self._write_log_file(file_name)

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(
user=self.user,
object_id=file_name,
content_type__isnull=True,
object_repr=f'删除日志: {file_name}',
).exists()
)
140 changes: 67 additions & 73 deletions inventory/views/sales.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,26 @@
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


SUPPORTED_PAYMENT_METHODS = {value for value, _label in Sale.PAYMENT_METHODS}


def normalize_payment_method(payment_method):
"""Normalize legacy payment values and reject flows the system cannot settle."""
payment_method = (payment_method or 'cash').strip()
if payment_method == 'account':
payment_method = 'balance'
if payment_method not in SUPPORTED_PAYMENT_METHODS:
raise ValueError('不支持的支付方式')
return payment_method

@login_required
def sale_list(request):
"""销售单列表视图"""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -329,13 +318,10 @@ def sale_create(request):
final_amount = total_amount
print(f"使用数据库价格计算的总金额: {total_amount}")

# 最终安全检查,确保总金额大于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
# 最终安全检查:金额无法计算时不能用固定值完成销售。
if (total_amount <= 0 or final_amount <= 0) and valid_products_data:
messages.error(request, '销售单创建失败,商品金额无效。')
return redirect('sale_create')

form = SaleForm(request.POST)
if form.is_valid():
Expand All @@ -358,9 +344,11 @@ def sale_create(request):
pass

# 设置支付方式。旧前端曾提交 account,后端统一按账户余额处理。
payment_method = request.POST.get('payment_method', 'cash')
if payment_method == 'account':
payment_method = 'balance'
try:
payment_method = normalize_payment_method(request.POST.get('payment_method', 'cash'))
except ValueError as e:
messages.error(request, str(e))
return redirect('sale_create')
sale.payment_method = payment_method

# 收银台是一次性下单并结算,直接标记为已完成
Expand Down Expand Up @@ -644,11 +632,11 @@ def sale_complete(request, sale_id):
sale.discount_amount = Decimal('0.00')

sale.final_amount = sale.total_amount - sale.discount_amount
if sale.final_amount <= 0:
raise ValueError('销售单金额无效,不能完成收款')
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_payment_method(request.POST.get('payment_method') or sale.payment_method)
sale.payment_method = payment_method

balance_amount = Decimal('0.00')
Expand Down Expand Up @@ -779,43 +767,49 @@ 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)
item = get_object_or_404(
SaleItem.objects.select_for_update().select_related('product'),
id=item_id,
sale=sale,
)

# 检查销售单状态
if sale.status != 'DRAFT':
messages.error(request, '只有未完成的销售单可以修改商品')
return redirect('sale_detail', sale_id=sale.id)

# 恢复库存
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)
Expand Down
Loading