This commit is contained in:
2026-06-09 13:44:53 +08:00
parent 5d73a7d036
commit fa2178fbd8
18 changed files with 168 additions and 40 deletions
@@ -10,6 +10,8 @@ const {
formatDateTime,
formatDate,
getPurchaseOrderStatusLabel,
getReceiptStatusLabel,
getPaymentStatusLabel,
isPurchaseOrderLocked,
loadPurchaseOrders,
loadSuppliers,
@@ -218,6 +220,23 @@ async function markAsPaid(orderId: number) {
}
}
async function updateStatus(orderId: number, status: string) {
const label = status === 'cancelled' ? '作废' : status
if (!confirm(`确认${label}该订单?`)) return
try {
await apiRequest(`/api/purchase-orders/${orderId}/status`, {
method: 'PATCH',
body: JSON.stringify({ status })
})
addNotification(`订单已${label}`, 'success')
loadPurchaseOrders()
loadMovements()
loadInventory()
} catch (e) {
handleApiError(e, '更新状态')
}
}
function closeModal() {
showModal.value = false
editingItem.value = null
@@ -267,9 +286,16 @@ onMounted(() => {
<el-table-column prop="supplier_name" label="供应商" />
<el-table-column label="状态">
<template #default="{ row }">
<el-tag :type="row.status === 'received' ? 'success' : row.status === 'paid' ? 'info' : 'warning'">
{{ getPurchaseOrderStatusLabel(row.status) }}
</el-tag>
<div style="display: flex; gap: 4px;">
<el-tag
:type="row.receipt_status === 'received' ? 'success' : row.receipt_status === 'partial_received' ? 'warning' : row.receipt_status === 'cancelled' ? 'danger' : 'info'"
size="small"
>{{ getReceiptStatusLabel(row.receipt_status) }}</el-tag>
<el-tag
:type="row.payment_status === 'paid' ? 'success' : 'info'"
size="small"
>{{ getPaymentStatusLabel(row.payment_status) }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="订单创建">
@@ -309,7 +335,7 @@ onMounted(() => {
删除
</el-button>
<el-button
v-if="row.status === 'pending'"
v-if="row.receipt_status === 'pending' || row.receipt_status === 'partial_received'"
type="success"
size="small"
@click="openReceiveDialog(row)"
@@ -317,13 +343,21 @@ onMounted(() => {
到货入库
</el-button>
<el-button
v-if="row.status === 'received'"
v-if="row.receipt_status === 'received' && row.payment_status !== 'paid'"
type="warning"
size="small"
@click="markAsPaid(row.id)"
>
标记为已付款
</el-button>
<el-button
v-if="row.payment_status !== 'paid' && row.receipt_status !== 'cancelled'"
type="info"
size="small"
@click="updateStatus(row.id, 'cancelled')"
>
作废
</el-button>
</template>
</el-table-column>
</el-table>
@@ -10,6 +10,8 @@ const {
formatDateTime,
formatDate,
getSalesOrderStatusLabel,
getDeliveryStatusLabel,
getPaymentStatusLabel,
loadProductionOrders,
loadFinishedProducts,
loadCustomers,
@@ -309,30 +311,36 @@ onMounted(() => {
<el-table-column label="实际收款">
<template #default="{ row }">{{ row.paid_at ? formatDateTime(row.paid_at) : '-' }}</template>
</el-table-column>
<el-table-column label="订单状态">
<el-table-column label="订单状态" width="160">
<template #default="{ row }">
<el-tag :type="row.status === 'paid' ? 'success' : row.status === 'delivered' ? 'warning' : 'info'">
{{ getSalesOrderStatusLabel(row.status) }}
</el-tag>
<div style="display: flex; gap: 4px;">
<el-tag
:type="row.delivery_status === 'delivered' ? 'warning' : row.delivery_status === 'cancelled' ? 'danger' : 'info'"
size="small"
>{{ getDeliveryStatusLabel(row.delivery_status) }}</el-tag>
<el-tag
:type="row.payment_status === 'paid' ? 'success' : 'info'"
size="small"
>{{ getPaymentStatusLabel(row.payment_status) }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="240">
<template #default="{ row }">
<el-dropdown style="margin-right: 8px;">
<el-button size="small">
状态 ▾
</el-button>
<el-dropdown style="margin-right: 8px;" v-if="row.delivery_status !== 'cancelled' && row.payment_status !== 'paid'">
<el-button size="small">状态 ▾</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="updateStatus(row.id, 'delivered')">已交付</el-dropdown-item>
<el-dropdown-item @click="updateStatus(row.id, 'paid')">已收款</el-dropdown-item>
<el-dropdown-item v-if="row.delivery_status === 'manufacturing'" @click="updateStatus(row.id, 'delivered')">已交付</el-dropdown-item>
<el-dropdown-item v-if="row.delivery_status === 'delivered' && row.payment_status !== 'paid'" @click="updateStatus(row.id, 'paid')">已收款</el-dropdown-item>
<el-dropdown-item v-if="row.payment_status !== 'paid'" @click="updateStatus(row.id, 'cancelled')" style="color: #f56c6c;">作废</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
type="primary"
size="small"
:disabled="row.status === 'paid'"
:disabled="row.payment_status === 'paid' || row.delivery_status === 'cancelled'"
@click="editOrder(row)"
>
编辑
@@ -212,25 +212,43 @@ export function useInventory() {
const statusMap: Record<string, string> = {
draft: '已下单',
pending: '已下单',
partial_received: '部分收货',
received: '已收货',
paid: '已付款'
paid: '已付款',
cancelled: '已作废'
}
return statusMap[status] || status
}
const isPurchaseOrderLocked = (status: string): boolean => {
return ['received', 'paid'].includes(status)
return ['received', 'paid', 'cancelled'].includes(status)
}
const getSalesOrderStatusLabel = (status: string): string => {
const statusMap: Record<string, string> = {
manufacturing: '制造中',
delivered: '已交付',
paid: '已收款'
paid: '已收款',
cancelled: '已作废'
}
return statusMap[status] || status
}
const getDeliveryStatusLabel = (ds: string): string => {
const map: Record<string, string> = { manufacturing: '制造中', delivered: '已交付', cancelled: '已作废' }
return map[ds] || ds
}
const getPaymentStatusLabel = (ps: string): string => {
const map: Record<string, string> = { unpaid: '未收款', paid: '已收款' }
return map[ps] || ps
}
const getReceiptStatusLabel = (rs: string): string => {
const map: Record<string, string> = { pending: '已下单', partial_received: '部分收货', received: '已收货', cancelled: '已作废' }
return map[rs] || rs
}
const checkBackendHealth = async () => {
try {
const resp = await fetch('/health', { method: 'GET' })
@@ -468,6 +486,9 @@ export function useInventory() {
getPurchaseOrderStatusLabel,
isPurchaseOrderLocked,
getSalesOrderStatusLabel,
getDeliveryStatusLabel,
getPaymentStatusLabel,
getReceiptStatusLabel,
checkBackendHealth,
loadDashboard,
loadFinishedProducts,
+35 -5
View File
@@ -39,6 +39,24 @@ from .utils import generate_order_no
router = APIRouter(prefix="/purchase-orders", tags=["采购订单"])
def _compute_receipt_status(order: PurchaseOrder) -> str:
"""收货状态:已下单 / 部分收货 / 已收货 / 已作废"""
if order.status == "cancelled":
return "cancelled"
if order.status in ("received", "paid"):
return "received"
if order.status == "partial_received":
return "partial_received"
return "pending"
def _compute_payment_status(order: PurchaseOrder) -> str:
"""付款状态:未付款 / 已付款"""
if order.status == "paid" or order.paid_date:
return "paid"
return "unpaid"
def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> PurchaseOrderResponse:
return PurchaseOrderResponse(
id=order.id,
@@ -47,6 +65,8 @@ def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) ->
order_date=order.order_date,
expected_date=order.expected_date,
status=order.status,
receipt_status=_compute_receipt_status(order),
payment_status=_compute_payment_status(order),
total_amount=order.total_amount,
paid_amount=order.paid_amount,
remark=order.remark,
@@ -92,6 +112,8 @@ async def _build_purchase_order_detail(
order_date=order.order_date,
expected_date=order.expected_date,
status=order.status,
receipt_status=_compute_receipt_status(order),
payment_status=_compute_payment_status(order),
total_amount=order.total_amount,
paid_amount=order.paid_amount,
remark=order.remark,
@@ -319,19 +341,27 @@ async def update_purchase_order_status(
if not new_status:
raise HTTPException(status_code=400, detail="状态不能为空")
valid_statuses = ["pending", "partial_received", "received", "paid"]
valid_statuses = ["pending", "partial_received", "received", "paid", "cancelled"]
if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"无效的状态值,有效值为: {valid_statuses}")
# 状态转换逻辑
if order.status == "paid":
raise HTTPException(status_code=400, detail="已付款的采购订单禁止修改状态")
if order.status == "cancelled":
raise HTTPException(status_code=400, detail="已作废的采购订单禁止修改状态")
if order.status == "received" and new_status != "paid":
raise HTTPException(status_code=400, detail="已收货的采购订单只能标记为已付款")
if order.status == "received" and new_status not in ("paid", "cancelled"):
raise HTTPException(status_code=400, detail="已收货的采购订单只能标记为已付款或已作废")
if order.status == "partial_received" and new_status not in ("received", "paid"):
raise HTTPException(status_code=400, detail="部分收货的采购订单只能标记为已收货或已付款")
if order.status == "partial_received" and new_status not in ("received", "paid", "cancelled"):
raise HTTPException(status_code=400, detail="部分收货的采购订单只能标记为已收货、已付款或已作废")
if order.status == "pending" and new_status not in ("received", "cancelled"):
raise HTTPException(status_code=400, detail="待收货的采购订单只能标记为已收货或已作废")
if new_status == "cancelled" and order.status == "paid":
raise HTTPException(status_code=400, detail="已付款的订单不能作废")
# 更新状态和对应时间
order.status = new_status
+36 -5
View File
@@ -44,10 +44,26 @@ from .schemas import (
from .utils import generate_order_no
router = APIRouter(prefix="/sales-orders", tags=["销售订单"])
VALID_ORDER_STATUSES = {"draft", "manufacturing", "delivered", "paid"}
VALID_ORDER_STATUSES = {"manufacturing", "delivered", "paid", "cancelled"}
PRODUCTION_STATUSES = {"not_started", "bom_missing", "material_issued", "completed"}
def _compute_delivery_status(order: SalesOrder) -> str:
"""物流状态:制造中 / 已交付 / 已作废"""
if order.status == "cancelled":
return "cancelled"
if order.status in ("delivered", "paid") or order.actual_delivery_date:
return "delivered"
return "manufacturing"
def _compute_payment_status(order: SalesOrder) -> str:
"""收款状态:未收款 / 已收款"""
if order.status == "paid" or order.actual_payment_date:
return "paid"
return "unpaid"
def _build_sales_order_response(order: SalesOrder, customer_name: str) -> SalesOrderResponse:
return SalesOrderResponse(
id=order.id,
@@ -59,6 +75,8 @@ def _build_sales_order_response(order: SalesOrder, customer_name: str) -> SalesO
actual_delivery_date=order.actual_delivery_date,
actual_payment_date=order.actual_payment_date,
status=order.status,
delivery_status=_compute_delivery_status(order),
payment_status=_compute_payment_status(order),
production_status=order.production_status or "not_started",
production_no=order.production_no,
planned_material_cost=Decimal(str(order.planned_material_cost or 0)),
@@ -90,6 +108,8 @@ async def _build_sales_order_detail_response(
actual_delivery_date=order.actual_delivery_date,
actual_payment_date=order.actual_payment_date,
status=order.status,
delivery_status=_compute_delivery_status(order),
payment_status=_compute_payment_status(order),
production_status=order.production_status or "not_started",
production_no=order.production_no,
planned_material_cost=Decimal(str(order.planned_material_cost or 0)),
@@ -508,16 +528,27 @@ async def update_sales_order_status(
raise HTTPException(status_code=400, detail="订单状态必须为 manufacturing、delivered、paid")
order, customer = await _get_sales_order_with_customer(db_session, order_id)
# 只禁止从已交付状态改为非已收款状态
if order.status == "delivered" and payload.status != "paid":
raise HTTPException(status_code=400, detail="已交付的销售订单只能修改为已收款状态")
# 状态转换守卫
if order.status == "paid":
raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改状态")
if order.status == "cancelled":
raise HTTPException(status_code=400, detail="已作废的销售订单禁止修改状态")
if order.status == "manufacturing" and payload.status == "paid":
raise HTTPException(status_code=400, detail="制造中的订单不能直接标记为已收款,请先标记为已交付")
if order.status == "delivered" and payload.status not in ("paid", "cancelled"):
raise HTTPException(status_code=400, detail="已交付的销售订单只能修改为已收款或已作废")
if payload.status == "cancelled" and order.status == "paid":
raise HTTPException(status_code=400, detail="已收款的订单不能作废,请联系管理员")
# 根据状态更新相应的日期字段
from datetime import datetime
if payload.status == "delivered" and not order.actual_delivery_date:
order.actual_delivery_date = datetime.now()
elif payload.status == "paid" and not order.actual_payment_date:
order.actual_payment_date = datetime.now()
elif payload.status == "cancelled" and not order.actual_delivery_date:
order.actual_delivery_date = datetime.now()
order.status = payload.status
await db_session.commit()
@@ -25,6 +25,8 @@ class PurchaseOrderResponse(BaseModel):
order_date: datetime
expected_date: Optional[date]
status: str
receipt_status: str # "pending" | "partial_received" | "received" | "cancelled"
payment_status: str # "unpaid" | "paid"
total_amount: Decimal
paid_amount: Decimal
remark: Optional[str]
@@ -32,6 +32,8 @@ class SalesOrderResponse(BaseModel):
actual_delivery_date: Optional[datetime]
actual_payment_date: Optional[datetime]
status: str
delivery_status: str # "manufacturing" | "delivered" | "cancelled"
payment_status: str # "unpaid" | "paid"
production_status: str
production_no: Optional[str]
planned_material_cost: Decimal
@@ -1 +1 @@
import{D as e,g as t,t as n,y as r}from"./index-d9a4ZfZS.js";var i={};function a(n,i){return e(),r(`div`,null,[...i[0]||=[t(`div`,{class:`page-header`},[t(`h1`,{class:`page-title`},`设计体系`),t(`p`,{class:`page-subtitle`},`迁移中...`)],-1)]])}var o=n(i,[[`render`,a]]);export{o as default};
import{D as e,g as t,t as n,y as r}from"./index-Tuyb_Kvo.js";var i={};function a(n,i){return e(),r(`div`,null,[...i[0]||=[t(`div`,{class:`page-header`},[t(`h1`,{class:`page-title`},`设计体系`),t(`p`,{class:`page-subtitle`},`迁移中...`)],-1)]])}var o=n(i,[[`render`,a]]);export{o as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{C as e,D as t,N as n,P as r,a as i,f as a,g as o,l as s,n as c,o as l,p as u,t as d,v as f,y as p,z as m}from"./index-d9a4ZfZS.js";var h={class:`login-container`},g={class:`login-card`},_={class:`form-group`},v={class:`form-group`},y={key:0,class:`form-error`},b=[`disabled`],x=d(e({__name:`LoginView`,setup(e){let d=s(),x=r({username:``,password:``,error:``,loading:!1}),S=async()=>{x.error=``,x.loading=!0;try{let e=await l(`/api/auth/login/json`,{method:`POST`,body:JSON.stringify({username:x.username,password:x.password})});i(e.access_token,e.user),c(`登录成功`,`success`),d.push(`/`)}catch(e){x.error=e.message||`登录失败`}finally{x.loading=!1}};return(e,r)=>(t(),p(`div`,h,[o(`div`,g,[r[4]||=o(`div`,{class:`login-header`},[o(`div`,{class:`login-logo`},`G`),o(`h1`,{class:`login-title`},`Gemold`),o(`p`,{class:`login-subtitle`},`模具制造管理系统`)],-1),o(`form`,{onSubmit:u(S,[`prevent`])},[o(`div`,_,[r[2]||=o(`label`,{class:`form-label`},`用户名`,-1),n(o(`input`,{"onUpdate:modelValue":r[0]||=e=>x.username=e,type:`text`,class:`form-input`,placeholder:`请输入用户名`,required:``},null,512),[[a,x.username]])]),o(`div`,v,[r[3]||=o(`label`,{class:`form-label`},`密码`,-1),n(o(`input`,{"onUpdate:modelValue":r[1]||=e=>x.password=e,type:`password`,class:`form-input`,placeholder:`请输入密码`,required:``},null,512),[[a,x.password]])]),x.error?(t(),p(`div`,y,m(x.error),1)):f(``,!0),o(`button`,{type:`submit`,class:`btn btn-primary w-full mt-4`,disabled:x.loading},m(x.loading?`登录中...`:`登录`),9,b)],32)])]))}}),[[`__scopeId`,`data-v-01da5b35`]]);export{x as default};
import{C as e,D as t,N as n,P as r,a as i,f as a,g as o,l as s,n as c,o as l,p as u,t as d,v as f,y as p,z as m}from"./index-Tuyb_Kvo.js";var h={class:`login-container`},g={class:`login-card`},_={class:`form-group`},v={class:`form-group`},y={key:0,class:`form-error`},b=[`disabled`],x=d(e({__name:`LoginView`,setup(e){let d=s(),x=r({username:``,password:``,error:``,loading:!1}),S=async()=>{x.error=``,x.loading=!0;try{let e=await l(`/api/auth/login/json`,{method:`POST`,body:JSON.stringify({username:x.username,password:x.password})});i(e.access_token,e.user),c(`登录成功`,`success`),d.push(`/`)}catch(e){x.error=e.message||`登录失败`}finally{x.loading=!1}};return(e,r)=>(t(),p(`div`,h,[o(`div`,g,[r[4]||=o(`div`,{class:`login-header`},[o(`div`,{class:`login-logo`},`G`),o(`h1`,{class:`login-title`},`Gemold`),o(`p`,{class:`login-subtitle`},`模具制造管理系统`)],-1),o(`form`,{onSubmit:u(S,[`prevent`])},[o(`div`,_,[r[2]||=o(`label`,{class:`form-label`},`用户名`,-1),n(o(`input`,{"onUpdate:modelValue":r[0]||=e=>x.username=e,type:`text`,class:`form-input`,placeholder:`请输入用户名`,required:``},null,512),[[a,x.username]])]),o(`div`,v,[r[3]||=o(`label`,{class:`form-label`},`密码`,-1),n(o(`input`,{"onUpdate:modelValue":r[1]||=e=>x.password=e,type:`password`,class:`form-input`,placeholder:`请输入密码`,required:``},null,512),[[a,x.password]])]),x.error?(t(),p(`div`,y,m(x.error),1)):f(``,!0),o(`button`,{type:`submit`,class:`btn btn-primary w-full mt-4`,disabled:x.loading},m(x.loading?`登录中...`:`登录`),9,b)],32)])]))}}),[[`__scopeId`,`data-v-01da5b35`]]);export{x as default};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{D as e,g as t,t as n,y as r}from"./index-d9a4ZfZS.js";var i={};function a(n,i){return e(),r(`div`,null,[...i[0]||=[t(`div`,{class:`page-header`},[t(`h1`,{class:`page-title`},`灰度发布与回滚`),t(`p`,{class:`page-subtitle`},`迁移中...`)],-1)]])}var o=n(i,[[`render`,a]]);export{o as default};
import{D as e,g as t,t as n,y as r}from"./index-Tuyb_Kvo.js";var i={};function a(n,i){return e(),r(`div`,null,[...i[0]||=[t(`div`,{class:`page-header`},[t(`h1`,{class:`page-title`},`灰度发布与回滚`),t(`p`,{class:`page-subtitle`},`迁移中...`)],-1)]])}var o=n(i,[[`render`,a]]);export{o as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -61,7 +61,7 @@
} catch (e) {}
})();
</script>
<script type="module" crossorigin src="/static/assets/index-d9a4ZfZS.js"></script>
<script type="module" crossorigin src="/static/assets/index-Tuyb_Kvo.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-DBUUNurD.css">
</head>
<body>