Compare commits

...

4 Commits

6 changed files with 285 additions and 10 deletions

View File

@ -14,7 +14,7 @@ from app.schemas.product import (
ProductResponse,
ProductScanResponse,
)
from app.services import product_service
from app.services import product_service, product_finalize_service
from app.services.auth_service import get_current_user
from app.services.qrcode_service import generate_qrcode_png
@ -147,6 +147,42 @@ async def update_product_overall_status(
)
# ============================================================
# 产品收口 — 管理员将产品修正为「已入库」/「已出库」(可反向纠错)
# ============================================================
class ProductFinalizeRequest(BaseModel):
"""管理员收口入参"""
status: str = Field(..., min_length=1, max_length=20, description="收口目标: 已入库 | 已出库")
note: str | None = Field(None, max_length=200, description="备注(选填)")
@router.post("/scan/{serial_number}/finalize", response_model=ProductScanResponse)
async def finalize_product_status_endpoint(
serial_number: str,
data: ProductFinalizeRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""管理员将产品整体收口为「已入库」或「已出库」(支持 入库<->出库 反向互切纠错)。
与 MOM 出入库回调落库语义一致:同步 product.overall_status/status、写
warehouse_inbound/outbound 日志、幂等追加「扫码入库/扫码出库」主线收尾节点。
权限:仅 SUPER_ADMIN / SUPERVISOR。
"""
from fastapi import HTTPException, status
from app.services.task_service import ADMIN_ROLES
if (current_user or {}).get("role") not in ADMIN_ROLES:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅超级管理员或主管可执行入库/出库收口",
)
return await product_finalize_service.finalize_product_status(
db, serial_number, data.status, current_user, data.note,
)
# ============================================================
# 协同留言板
# ============================================================

View File

@ -0,0 +1,146 @@
"""产品收口服务 — 管理员一键把产品修正为「已入库」或「已出库」(可反向互切纠错)。
落库语义与 MOM 仓储回调webhooks.mom_inbound / mom_outbound保持一致
1) 改 product.overall_status + product.status映射为 ARCHIVED / OUTBOUND
2) 写一条 warehouse_inbound / warehouse_outbound 的 task_log
3) 幂等追加「扫码入库 / 扫码出库」WAREHOUSE 主线节点 + TaskRecord。
与 update_overall_status仅改状态、面向主线负责人不同本服务面向管理员做收口
会补全流转树收尾节点,使任务全景/详情/矩阵能正确显示入库/出库收口。
"""
from __future__ import annotations
from fastapi import HTTPException
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time_utils import get_beijing_time
from app.models.product import Product
from app.models.task import Task, TaskRecord
from app.models.task_log import TaskLog
from app.services.product_service import get_product_by_serial
# target -> (product.status 映射码, 追加节点名, task_log.action_type)
_FINALIZE_MAP = {
"已入库": ("ARCHIVED", "扫码入库", "warehouse_inbound"),
"已出库": ("OUTBOUND", "扫码出库", "warehouse_outbound"),
}
async def _append_warehouse_node(db: AsyncSession, product: Product, task_name: str) -> None:
"""在流转树主干末尾幂等追加仓储收口主线节点 + TaskRecord对齐 webhooks._append_warehouse_task"""
existing = (
await db.execute(
select(Task.id).where(
Task.product_id == product.id,
Task.task_name == task_name,
).limit(1)
)
).scalar_one_or_none()
if existing:
return
last_main = (
await db.execute(
select(Task)
.where(
Task.product_id == product.id,
(Task.parent_task_id.is_(None))
| (Task.task_type.in_(["TRANSFER", "RECOVERY"])),
)
.order_by(Task.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
node = Task(
product_id=product.id,
parent_task_id=last_main.id if last_main else None,
task_name=task_name,
assignee_id=None, # ★ 不能填非 UUID否则前端头像解析报错导致节点跳过渲染
status="COMPLETED",
task_type="WAREHOUSE",
completed_at=get_beijing_time(),
remark=f"管理员收口:追加{task_name}收尾节点",
)
db.add(node)
await db.flush()
db.add(TaskRecord(task_id=node.id, remark=f"管理员收口:追加{task_name}收尾节点"))
async def finalize_product_status(
db: AsyncSession,
serial_number: str,
target: str,
current_user: dict | None,
note: str | None = None,
):
"""把产品整体收口到 target已入库 / 已出库)。已处于目标态则幂等直接返回。"""
target = (target or "").strip()
if target not in _FINALIZE_MAP:
raise HTTPException(status_code=400, detail="收口目标状态仅支持:已入库 / 已出库")
product = (
await db.execute(select(Product).where(Product.serial_number == serial_number))
).scalar_one_or_none()
if product is None:
raise HTTPException(status_code=404, detail=f"未找到序列号为 {serial_number} 的产品")
# 幂等:已在目标状态则不动(避免重复写日志/节点)
if product.overall_status == target:
return await get_product_by_serial(db, serial_number)
# 管理员强收口:仍有在产/待接收任务的,一并结束为完工收口,避免宏观口径分裂
res = await db.execute(
update(Task)
.where(Task.product_id == product.id, Task.status.in_(["WIP", "PENDING"]))
.values(
status="COMPLETED",
received_at=get_beijing_time(),
completed_at=get_beijing_time(),
)
)
ended_active = res.rowcount or 0
status_code, node_name, action_type = _FINALIZE_MAP[target]
product.overall_status = target
product.status = status_code
product.current_location_id = "virtual_warehouse"
# task_log 绑目标任务:优先「在库」任务,其次该产品最新任务(对齐 webhook
log_task = (
await db.execute(
select(Task)
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
.order_by(Task.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
if log_task is None:
log_task = (
await db.execute(
select(Task).where(Task.product_id == product.id)
.order_by(Task.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
operator = (current_user or {}).get("username") or "virtual_warehouse"
remark = f"管理员[{operator}]收口为{target}"
if ended_active:
remark += f";一并结束{ended_active}个进行中/待接收任务"
if note and note.strip():
remark += f";备注:{note.strip()}"
if log_task is not None:
db.add(
TaskLog(
task_id=log_task.id,
operator_id=str(operator)[:64],
action_type=action_type,
remark=remark,
)
)
await _append_warehouse_node(db, product, node_name)
await db.commit()
return await get_product_by_serial(db, serial_number)

View File

@ -522,6 +522,7 @@ async def get_all_products(
stmt = stmt.outerjoin(ProductionOrder, Product.order_id == ProductionOrder.id).where(
or_(
Product.serial_number.ilike(kw),
Product.external_serial.ilike(kw), # 业务序列号(用户自定义)
Product.material_name.ilike(kw),
cast(Product.material_id, String).ilike(kw),
Product.spec_model.ilike(kw),

View File

@ -12,6 +12,7 @@ import {
getLabelPreview, executePrint,
} from "../../services/printApi";
import { useToast } from "../../components/ui/Toast";
import { useAuth } from "../../contexts/AuthContext";
import { getStatusConfig } from "../../constants/task";
const QR_BASE = "/api/v1/products/qrcode";
@ -28,6 +29,8 @@ interface ProductGroup { groupKey: string; products: ProductResponse[]; allInWar
export default function AdminProductsPage() {
const { toast } = useToast();
const { user: authUser } = useAuth();
const isAdmin = authUser?.role === "SUPER_ADMIN" || authUser?.role === "SUPERVISOR";
const [products, setProducts] = useState<ProductResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -46,6 +49,15 @@ export default function AdminProductsPage() {
const [printCopies, setPrintCopies] = useState(1);
const [printing, setPrinting] = useState(false);
// 收口(入库/出库,仅管理员,需倒计时+输入身份证确认)
const [finalizeTarget, setFinalizeTarget] = useState<ProductResponse | null>(null);
const [finalizeStatus, setFinalizeStatus] = useState<"已入库" | "已出库">("已入库");
const [finalizeNote, setFinalizeNote] = useState("");
const [finalizeSerialInput, setFinalizeSerialInput] = useState("");
const [finalizeCountdown, setFinalizeCountdown] = useState(5);
const [finalizing, setFinalizing] = useState(false);
const canConfirmFinalize = !!finalizeTarget && finalizeCountdown <= 0 && finalizeSerialInput === finalizeTarget.serial_number;
// 编辑
const [editTarget, setEditTarget] = useState<ProductResponse | null>(null);
const [editOrderNo, setEditOrderNo] = useState("");
@ -131,6 +143,34 @@ export default function AdminProductsPage() {
function confirmDelete(p: ProductResponse) { setDeleteTarget(p); }
async function handleDelete() { if (!deleteTarget) return; setDeleting(true); try { await api.delete(`/products/${deleteTarget.id}`); toast("已删除", "success"); setDeleteTarget(null); loadProducts(); } catch (err: any) { toast(err?.response?.data?.detail ?? "删除失败", "error"); } finally { setDeleting(false); } }
// ---- 状态收口(入库 / 出库,管理员)----
// 🛡️ 双重确认倒计时 + 必须输入产品身份证,防误触
useEffect(() => {
if (!finalizeTarget) { setFinalizeCountdown(5); setFinalizeSerialInput(""); return; }
setFinalizeCountdown(5); setFinalizeSerialInput("");
const timer = setInterval(() => setFinalizeCountdown(c => { if (c <= 1) { clearInterval(timer); return 0; } return c - 1; }), 1000);
return () => clearInterval(timer);
}, [finalizeTarget?.id]); // eslint-disable-line react-hooks/exhaustive-deps
function openFinalize(p: ProductResponse) { setFinalizeTarget(p); setFinalizeStatus("已入库"); setFinalizeNote(""); setFinalizeSerialInput(""); setFinalizeCountdown(5); }
async function handleFinalize() {
if (!canConfirmFinalize) return;
setFinalizing(true);
try {
await api.post(`/products/scan/${finalizeTarget.serial_number}/finalize`, {
status: finalizeStatus,
note: finalizeNote.trim() || undefined,
});
toast(`已收口为「${finalizeStatus}`, "success");
setFinalizeTarget(null);
loadProducts();
} catch (err: any) {
toast(err?.response?.data?.detail ?? "操作失败", "error");
} finally {
setFinalizing(false);
}
}
return (
<div>
{/* 标题栏 */}
@ -164,7 +204,7 @@ export default function AdminProductsPage() {
<form onSubmit={handleSearch} className="flex items-center gap-2">
<div className="relative flex-1 max-w-xl">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input type="text" value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索产品身份证、订单号、规格型号..." className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" />
<input type="text" value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索产品身份证、业务序列号、订单号、名称、规格型号..." className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100" />
</div>
<button type="submit" disabled={loading} className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
@ -262,7 +302,12 @@ export default function AdminProductsPage() {
<InfoRow icon={CalendarDays} label="生产总天数(工作日)" value={p.production_days_workdays != null ? `${p.production_days_workdays}` : "—"} />
<InfoRow icon={Clock} label="创建时间" value={new Date(p.created_at).toLocaleDateString("zh-CN")} />
</div>
<div className="border-t border-gray-50 px-4 py-3">
<div className="space-y-2 border-t border-gray-50 px-4 py-3">
{isAdmin && (
<button onClick={(e) => { e.stopPropagation(); openFinalize(p); }} className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-indigo-200 bg-indigo-50 py-2 text-xs font-medium text-indigo-700 hover:bg-indigo-100 hover:border-indigo-300">
🏷 /
</button>
)}
<button onClick={(e) => { e.stopPropagation(); handleOpenPrint(p); }} className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 py-2 text-xs font-medium text-blue-700 hover:bg-blue-100 hover:border-blue-300">
<Printer className="h-3.5 w-3.5" />
</button>
@ -310,6 +355,52 @@ export default function AdminProductsPage() {
</div>
)}
{/* 收口弹窗(入库/出库,仅管理员) */}
{finalizeTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => !finalizing && setFinalizeTarget(null)} />
<div className="relative z-10 mx-4 w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl">
<div className="mb-4 flex items-center justify-between"><h3 className="text-base font-bold text-gray-800"></h3><button onClick={() => setFinalizeTarget(null)} disabled={finalizing} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X className="h-4 w-4" /></button></div>
<p className="mb-1 font-mono text-sm font-bold text-gray-700">{finalizeTarget.serial_number}</p>
<p className="mb-4 text-xs text-gray-500">{getStatusConfig(finalizeTarget.macro_status || finalizeTarget.status).label || "—"} {finalizeTarget.current_location_name || finalizeTarget.current_location_id || "—"}</p>
<div className="mb-4 space-y-2">
{(["已入库", "已出库"] as const).map(s => (
<label key={s} onClick={(e) => e.stopPropagation()} className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2.5 text-sm ${finalizeStatus === s ? "border-indigo-400 bg-indigo-50 text-indigo-700" : "border-gray-200 text-gray-700 hover:bg-gray-50"}`}>
<input type="radio" name="finalizeStatus" checked={finalizeStatus === s} onChange={() => setFinalizeStatus(s)} className="accent-indigo-600" />
{s === "已入库" ? "入库(仓库实收 / ARCHIVED" : "出库(发货 / OUTBOUND"}
</label>
))}
</div>
<p className="mb-3 text-xs text-amber-600"> /</p>
<div className="mb-4"><label className="mb-1 block text-xs font-medium text-gray-600"></label><input value={finalizeNote} onChange={(e) => setFinalizeNote(e.target.value)} placeholder="例如:纠错补录" className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none" disabled={finalizing} /></div>
<div className="mb-4">
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<input
value={finalizeSerialInput}
onChange={(e) => setFinalizeSerialInput(e.target.value)}
placeholder={finalizeTarget.serial_number}
maxLength={16}
disabled={finalizing}
className="w-full rounded-lg border border-gray-200 px-3 py-2 font-mono text-sm focus:border-red-400 focus:outline-none"
/>
<p className={`mt-1 text-[11px] ${finalizeCountdown > 0 ? "text-amber-600" : "text-red-600"}`}>
{finalizeCountdown > 0
? `${finalizeCountdown}s 确认等待中:请核对目标状态与产品身份证`
: finalizeSerialInput === finalizeTarget.serial_number
? "✅ 已核验,可确认收口"
: `⚠ 请完整输入:${finalizeTarget.serial_number}`}
</p>
</div>
<div className="flex items-center justify-end gap-2">
<button onClick={() => setFinalizeTarget(null)} disabled={finalizing} className="rounded-lg border px-4 py-2 text-sm text-gray-600"></button>
<button onClick={handleFinalize} disabled={!canConfirmFinalize || finalizing} className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50">
{finalizing ? (<><Loader2 className="h-3.5 w-3.5 animate-spin" />...</>) : (finalizeCountdown > 0 ? `确认收口为 ${finalizeStatus} (${finalizeCountdown}s)` : `确认收口为 ${finalizeStatus}`)}
</button>
</div>
</div>
</div>
)}
{/* 删除确认弹窗 */}
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center">

View File

@ -573,7 +573,7 @@ export default function AdminTasksPage() {
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索产品身份证、订单号、规格型号..."
placeholder="搜索产品身份证、业务序列号、订单号、名称、规格型号..."
className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-8 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
{keyword && (

View File

@ -44,6 +44,10 @@
<text class="lock-title">🔒 沉浸工作区</text>
</view>
<view v-if="isAdminProxy" class="admin-proxy-notice">
<text class="proxy-text"> 正在以管理员身份代 [{{ formatUserName(lockedTask.assignee_id) || lockedTask.assignee_id }}] 操作</text>
</view>
<view class="focus-card" :class="statusColor(lockedTask.status)">
<view class="fc-header">
<text class="tag-branch">{{ branchLabelsMap[lockedTask.id] || '' }}</text>
@ -78,9 +82,6 @@
<view class="fc-time">{{ formatTaskTime(lockedTask) }}</view>
</view>
<view v-if="isAdminProxy" class="admin-proxy-notice">
<text class="proxy-text"> 正在以管理员身份代 [{{ formatUserName(lockedTask.assignee_id) || lockedTask.assignee_id }}] 操作</text>
</view>
<view v-if="isAssignee" class="footer-actions">
<button v-if="lockedTask.status === 'WIP'" class="footer-btn footer-transfer"
@tap="$emit('action', { task: lockedTask, type: 'transfer' })"><text class="btn-icon">🔄</text><text class="btn-txt">完工转交</text></button>
@ -270,7 +271,7 @@ export default {
.lock-title { font-size: 14px; font-weight: 700; color: #1f2937; }
.branch-done { font-size: 11px; font-weight: 600; color: #16a34a; }
.task-count { font-size: 12px; color: #9ca3af; display: block; margin-top: 4px; }
.focus-card { height: 100%; margin: 0 4px; padding: 20px 16px; border-radius: 16px; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow-y: auto; border-top: 5px solid #3b82f6; }
.focus-card { flex: 1; min-height: 0; margin: 0 4px; padding: 20px 16px calc(160rpx + env(safe-area-inset-bottom)); border-radius: 16px; background: #fff; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow-y: auto; border-top: 5px solid #3b82f6; }
.focus-card.s-yellow { border-top-color: #f59e0b; }
.focus-card.s-blue { border-top-color: #3b82f6; }
.focus-card.s-green { border-top-color: #22c55e; }
@ -292,13 +293,13 @@ export default {
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 24px 0; }
.zero-icon { font-size: 40px; margin-bottom: 8px; }
.zero-text { font-size: 14px; color: #9ca3af; }
.footer-actions { display: flex; gap: 12rpx; padding: 16rpx 20rpx; padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); background: #ffffff; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05); flex-shrink: 0; }
.footer-actions { position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: flex; gap: 12rpx; padding: 16rpx 20rpx; padding-bottom: calc(16rpx + env(safe-area-inset-bottom)); background: #ffffff; border-top: 1px solid #e5e7eb; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.05); }
.footer-btn { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100rpx; border: none; border-radius: 16rpx; line-height: 1.2; padding: 0; }
.footer-btn::after { border: none; }
.btn-icon { font-size: 36rpx; margin-bottom: 6rpx; }
.btn-txt { font-size: 24rpx; font-weight: 700; }
.footer-readonly { justify-content: center; background: #fef2f2; }
.admin-proxy-notice { padding: 10rpx 20rpx; background: #fef9e7; border-top: 2rpx solid #fde68a; flex-shrink: 0; }
.admin-proxy-notice { padding: 10rpx 20rpx; background: #fef9e7; border-bottom: 2rpx solid #fde68a; flex-shrink: 0; }
.proxy-text { font-size: 22rpx; color: #b45309; font-weight: 600; }
.readonly-hint { font-size: 24rpx; color: #dc2626; font-weight: 600; }
.footer-transfer { background: #dcfce7; color: #16a34a; }