Artifact d84e6650242b3a4fa443a2508adf61804f7bbfe6d890f7ec33d33649edf5e181:
- File
web/src/pages/Waves.tsx
— part of check-in
[d49666a1ca]
at
2026-10-23 06:33:00
on branch trunk
— 波次组单,按到期日占用库存并排出拣货路线
- 制单员把同一作业仓的待发货订单合成波次,相同商品汇总后优先占用更早到期的批次 - 拣货行按库位编码排列,缺货单独标出;组单和撤销都不扣库存 - 未完成波次会占住推荐数量,避免下一张波次重复分配 (user: pluto size: 10769)
import { useEffect, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { api, qty } from '../api' import { useSession } from '../session' import { Banner, Empty, Page, Pager } from '../ui' type Summary = { bill_id: number bill_code: string customer_name: string bill_date: string total_qty: number } type WaveSummary = { id: string status: string status_name: string order_count: number pick_lines: number short_qty: number created_by: string created_at: string } type Allocation = { bill_code: string; qty: number } type Pick = { line_no: number goods_name: string goods_code: string barcode: string location_code: string batch_no: string expires_on: string qty: number short_qty: number base_unit: string allocations: Allocation[] } type Wave = WaveSummary & { warehouse_name: string orders: { bill_id: number; bill_code: string; customer_name: string }[] picks: Pick[] } export function Waves() { const { waveId } = useParams() if (waveId) return <WaveDetail waveId={waveId} /> return <WaveList /> } function WaveList() { const navigate = useNavigate() const { user } = useSession() const warehouseId = user?.current_warehouse_id ?? 0 const [orders, setOrders] = useState<Summary[]>([]) const [orderTotal, setOrderTotal] = useState(0) const [orderOffset, setOrderOffset] = useState(0) const [waves, setWaves] = useState<WaveSummary[]>([]) const [waveTotal, setWaveTotal] = useState(0) const [waveOffset, setWaveOffset] = useState(0) const [selected, setSelected] = useState<number[]>([]) const [error, setError] = useState('') const [pending, setPending] = useState(false) const [ready, setReady] = useState(false) const limit = 50 useEffect(() => { if (warehouseId <= 0) return let stopped = false setReady(false) Promise.all([ api<{ total: number; orders: Summary[] }>( `/api/waves/candidates?warehouse_id=${warehouseId}&limit=${limit}&offset=${orderOffset}`, ), api<{ total: number; waves: WaveSummary[] }>( `/api/waves?warehouse_id=${warehouseId}&limit=${limit}&offset=${waveOffset}`, ), ]) .then(([ordersPage, wavesPage]) => { if (stopped) return setOrders(ordersPage.orders) setOrderTotal(ordersPage.total) setWaves(wavesPage.waves) setWaveTotal(wavesPage.total) setError('') }) .catch((err: unknown) => { if (!stopped) setError(err instanceof Error ? err.message : '加载失败') }) .finally(() => { if (!stopped) setReady(true) }) return () => { stopped = true } }, [warehouseId, orderOffset, waveOffset]) function toggle(billId: number) { setSelected((current) => current.includes(billId) ? current.filter((id) => id !== billId) : [...current, billId], ) } async function compose() { if (selected.length === 0 || pending) return if (!window.confirm(`把 ${selected.length} 张订单组成波次?不会扣减库存。`)) return setPending(true) setError('') try { const data = await api<{ wave: Wave }>('/api/waves', { method: 'POST', body: JSON.stringify({ bill_ids: selected }), }) navigate(`/waves/${data.wave.id}`) } catch (err) { setError(err instanceof Error ? err.message : '组单失败') setPending(false) } } return ( <Page title="波次" extra={ <button type="button" className="primary" disabled={selected.length === 0 || pending || warehouseId <= 0} onClick={compose}> 组成波次{selected.length > 0 ? `(${selected.length})` : ''} </button> } > <Banner kind="error" text={error} /> {warehouseId <= 0 && <Banner kind="error" text="请先在右上角选择当前作业仓库" />} <p className="muted">按到期日占用库存,再按库位编码排拣货路线。组单不扣库存,撤销后释放占用。</p> <h2>待组单</h2> {warehouseId <= 0 ? null : ready && orders.length === 0 ? ( <Empty text="没有待组单的订单" /> ) : ( <div className="table-wrap"> <table> <thead> <tr> <th></th> <th>单号</th> <th>客户</th> <th>日期</th> <th>数量</th> </tr> </thead> <tbody> {orders.map((order) => ( <tr key={order.bill_id}> <td> <input type="checkbox" checked={selected.includes(order.bill_id)} onChange={() => toggle(order.bill_id)} aria-label={order.bill_code} /> </td> <td> <Link to={`/orders/${order.bill_id}`}>{order.bill_code}</Link> </td> <td>{order.customer_name}</td> <td>{order.bill_date}</td> <td>{qty(order.total_qty)}</td> </tr> ))} </tbody> </table> </div> )} <Pager total={orderTotal} limit={limit} offset={orderOffset} onPage={setOrderOffset} /> <h2>波次</h2> {warehouseId <= 0 ? null : ready && waves.length === 0 ? ( <Empty text="还没有波次" /> ) : ( <div className="table-wrap"> <table> <thead> <tr> <th>波次</th> <th>状态</th> <th>订单</th> <th>拣货行</th> <th>缺货</th> <th>创建</th> </tr> </thead> <tbody> {waves.map((row) => ( <tr key={row.id}> <td> <Link to={`/waves/${row.id}`}>{row.id}</Link> </td> <td>{row.status_name}</td> <td>{row.order_count}</td> <td>{row.pick_lines}</td> <td>{row.short_qty > 0 ? qty(row.short_qty) : ''}</td> <td> {row.created_at} <span className="muted"> {row.created_by}</span> </td> </tr> ))} </tbody> </table> </div> )} <Pager total={waveTotal} limit={limit} offset={waveOffset} onPage={setWaveOffset} /> </Page> ) } function WaveDetail({ waveId }: { waveId: string }) { const [row, setRow] = useState<Wave | null>(null) const [error, setError] = useState('') const [pending, setPending] = useState(false) function load() { api<{ wave: Wave }>(`/api/waves/${waveId}`) .then((data) => { setRow(data.wave) setError('') }) .catch((err: unknown) => setError(err instanceof Error ? err.message : '加载失败')) } useEffect(load, [waveId]) async function cancel() { if (!row || pending) return if (!window.confirm('撤销这个波次?占用的推荐数量会释放。')) return setPending(true) setError('') try { const data = await api<{ wave: Wave }>(`/api/waves/${row.id}/cancel`, { method: 'POST' }) setRow(data.wave) } catch (err) { setError(err instanceof Error ? err.message : '撤销失败') } finally { setPending(false) } } return ( <Page title={row ? row.id : '波次'} extra={ row?.status === 'open' ? ( <button type="button" className="ghost" disabled={pending} onClick={cancel}> 撤销波次 </button> ) : null } > <Link className="back" to="/waves"> 返回列表 </Link> <Banner kind="error" text={error} /> {row && ( <> <div className="cards"> <div className="card"> <span>状态</span> <strong>{row.status_name}</strong> </div> <div className="card"> <span>仓库</span> <strong>{row.warehouse_name}</strong> </div> <div className="card"> <span>订单</span> <strong>{row.orders.length}</strong> </div> <div className="card"> <span>创建</span> <strong>{row.created_by}</strong> </div> </div> <h2>订单</h2> <div className="table-wrap"> <table> <thead> <tr> <th>单号</th> <th>客户</th> </tr> </thead> <tbody> {row.orders.map((order) => ( <tr key={order.bill_id}> <td> <Link to={`/orders/${order.bill_id}`}>{order.bill_code}</Link> </td> <td>{order.customer_name}</td> </tr> ))} </tbody> </table> </div> <h2>拣货路线</h2> <div className="table-wrap"> <table> <thead> <tr> <th>顺序</th> <th>库位</th> <th>商品</th> <th>批次</th> <th>到期</th> <th>数量</th> <th>分到订单</th> </tr> </thead> <tbody> {row.picks.map((line) => ( <tr key={line.line_no} className={line.short_qty > 0 ? 'short' : ''}> <td>{line.line_no}</td> <td className="mono">{line.location_code || '缺货'}</td> <td> {line.goods_name} <div className="muted"> {line.goods_code} {line.barcode ? ` / ${line.barcode}` : ''} </div> </td> <td className="mono">{line.batch_no}</td> <td>{line.expires_on}</td> <td> {line.short_qty > 0 ? `缺 ${qty(line.short_qty)} ${line.base_unit}` : `${qty(line.qty)} ${line.base_unit}`} </td> <td>{line.allocations.map((item) => `${item.bill_code} ${qty(item.qty)}`).join(',')}</td> </tr> ))} </tbody> </table> </div> </> )} </Page> ) }