Fosi Fossil Service
Small teams. Open source. Built on Fossil.

IaC / SwiftFlow

High-performance WMS with wave picking, smart routing, and real-time inventory visibility.

Public | Owner: IaC | Default branch: trunk | Last update: 2026-10-23 14:37

Artifact d84e6650242b3a4fa443a2508adf61804f7bbfe6d890f7ec33d33649edf5e181:


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>
  )
}

Clone

You can clone this repository with the following command:

fossil clone https://fosi.dev/IaC/SwiftFlow

Contribute

The best way to contribute is through e-mail. See the project documentation for more details.