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
6 changes: 3 additions & 3 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">
<i class="bi bi-journal-text"></i>
{% if request.LANGUAGE_CODE == 'en' %}Credit{% else %}挂账{% endif %}
<button type="button" class="payment-btn" data-payment="other">
<i class="bi bi-three-dots"></i>
{% 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
36 changes: 36 additions & 0 deletions inventory/templates/inventory/system/delete_log_file.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{% extends 'inventory/base.html' %}

{% block title %}删除日志文件 - {{ block.super }}{% endblock %}

{% block content %}
<div class="container-fluid px-4">
<h1 class="mt-4">删除日志文件</h1>

<div class="card mb-4">
<div class="card-header bg-danger text-white">
<i class="fas fa-trash me-1"></i>
确认删除
</div>
<div class="card-body">
<div class="alert alert-warning">
确定要删除日志文件 <strong>{{ file_name }}</strong> 吗?此操作不可撤销。
</div>

<form method="post">
{% csrf_token %}
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="confirm" name="confirm" required>
<label class="form-check-label" for="confirm">
我确认要删除此日志文件
</label>
</div>

<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-1"></i> 删除
</button>
<a href="{% url 'log_list' %}" class="btn btn-outline-secondary ms-2">取消</a>
</form>
</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 @@ -160,6 +160,34 @@ def test_delete_item_persists_recalculated_total(self):
sale.refresh_from_db()
self.assertEqual(sale.total_amount, Decimal('20.00')) # 删除后总额已落库

def test_delete_item_get_does_not_mutate_inventory_or_items(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('12.00'),
discount_amount=Decimal('0.00'),
final_amount=Decimal('12.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('12.00'))
self.assertEqual(sale.final_amount, Decimal('12.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 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')
20 changes: 20 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,23 @@ 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'))

def test_sale_create_rejects_unimplemented_mixed_payment(self):
response = self.client.post(reverse('sale_create'), self.sale_post_data('mixed'))

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'))
38 changes: 38 additions & 0 deletions inventory/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from django.core import management
from django.test import TestCase, Client
from django.urls import reverse
from django.conf import settings
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import User, Permission, Group
from decimal import Decimal

Expand Down Expand Up @@ -321,3 +323,39 @@ 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)
self.log_path = os.path.join(settings.LOG_DIR, 'test-admin.log')
with open(self.log_path, 'w', encoding='utf-8') as log_file:
log_file.write('important audit line\n')
self.addCleanup(lambda: os.path.exists(self.log_path) and os.remove(self.log_path))

def test_download_log_file_records_nullable_content_type_audit(self):
response = self.client.get(reverse('download_log_file', args=['test-admin.log']))

self.assertEqual(response.status_code, 200)
log_entry = LogEntry.objects.get(object_id='test-admin.log', action_flag=1)
self.assertIsNone(log_entry.content_type_id)

def test_delete_log_file_records_nullable_content_type_audit(self):
response = self.client.post(
reverse('delete_log_file', args=['test-admin.log']),
{'confirm': 'on'},
)

self.assertRedirects(response, reverse('log_list'))
self.assertFalse(os.path.exists(self.log_path))
log_entry = LogEntry.objects.get(object_id='test-admin.log', action_flag=3)
self.assertIsNone(log_entry.content_type_id)
136 changes: 65 additions & 71 deletions inventory/views/sales.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,24 @@
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


VALID_PAYMENT_METHODS = {value for value, _label in Sale.PAYMENT_METHODS}
ONE_STEP_PAYMENT_METHODS = VALID_PAYMENT_METHODS - {'mixed'}


def normalize_payment_method(payment_method):
"""Normalize legacy frontend values before validating sale payment flows."""
if payment_method == 'account':
return 'balance'
return payment_method

@login_required
def sale_list(request):
"""销售单列表视图"""
Expand Down Expand Up @@ -80,31 +92,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 +318,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, '销售单创建失败,商品金额无效。')
return redirect('sale_create')

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

# 设置支付方式。旧前端曾提交 account,后端统一按账户余额处理。
payment_method = request.POST.get('payment_method', 'cash')
if payment_method == 'account':
payment_method = 'balance'
payment_method = normalize_payment_method(request.POST.get('payment_method', 'cash'))
if payment_method not in ONE_STEP_PAYMENT_METHODS:
messages.error(request, '不支持的支付方式')
return redirect('sale_create')
sale.payment_method = payment_method

# 收银台是一次性下单并结算,直接标记为已完成
Expand Down Expand Up @@ -646,9 +631,11 @@ 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_payment_method(
request.POST.get('payment_method') or sale.payment_method
)
if payment_method not in VALID_PAYMENT_METHODS:
raise ValueError('不支持的支付方式')
sale.payment_method = payment_method

balance_amount = Decimal('0.00')
Expand Down Expand Up @@ -779,43 +766,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()
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)
Expand Down
Loading