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 d72f0828a471872c5463888ae25901a67a0bc464ef22ef6bb956589526242bb0:


import { useEffect, useState, type FormEvent } from 'react'
import { useSearchParams } from 'react-router-dom'
import { api, qty } from '../api'
import { useSession } from '../session'
import { Banner, Field, Page } from '../ui'

type Target = {
  batch_code: string
  bill_code: string
  warehouse_id: number
  warehouse_name: string
  goods_code: string
  goods_name: string
  barcode: string
  base_qty: number
  base_unit: string
  put_away_qty: number
  produced_on: string
  expires_on: string
  dates_locked: boolean
  location_code: string
  reason: string
}

type Spot = { code: string; enabled: boolean }

export function Putaway() {
  const { user } = useSession()
  const [params] = useSearchParams()
  const [code, setCode] = useState(params.get('code') ?? '')
  const [target, setTarget] = useState<Target | null>(null)
  const [produced, setProduced] = useState('')
  const [expires, setExpires] = useState('')
  const [amount, setAmount] = useState('')
  const [location, setLocation] = useState('')
  const [reason, setReason] = useState('')
  const [spots, setSpots] = useState<Spot[]>([])
  const [locationTouched, setLocationTouched] = useState(false)
  const [error, setError] = useState('')
  const [ok, setOk] = useState('')
  const [pending, setPending] = useState(false)

  useEffect(() => {
    const initial = params.get('code')
    if (initial) {
      setCode(initial)
      void lookup(initial, '')
    }
    // 只在从入库单带批次码进来时查一次。
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  async function lookup(batchCode: string, expiresOn: string, keepLocation = false) {
    setError('')
    setOk('')
    const data = await api<Target>('/api/putaways/recommend', {
      method: 'POST',
      body: JSON.stringify({ batch_code: batchCode, expires_on: expiresOn }),
    })
    setTarget(data)
    if (data.dates_locked) {
      setProduced(data.produced_on)
      setExpires(data.expires_on)
    } else if (!expiresOn) {
      setProduced(data.produced_on)
      setExpires(data.expires_on)
    }
    if (!keepLocation) {
      const remaining = data.base_qty - data.put_away_qty
      setAmount(remaining > 0 ? String(roundQty(remaining)) : '')
      setLocationTouched(false)
    }
    setReason(data.reason)
    if (!keepLocation || !locationTouched) setLocation(data.location_code)
    const places = await api<{ locations: Spot[] }>(
      `/api/locations?warehouse_id=${data.warehouse_id}`,
    )
    setSpots(places.locations.filter((row) => row.enabled))
    return data
  }

  async function onSearch(event: FormEvent) {
    event.preventDefault()
    setPending(true)
    try {
      await lookup(code, '')
    } catch (err) {
      setTarget(null)
      setError(err instanceof Error ? err.message : '查询失败')
    } finally {
      setPending(false)
    }
  }

  useEffect(() => {
    if (!target || target.dates_locked || expires.length !== 10) return
    const timer = setTimeout(() => {
      lookup(target.batch_code, expires, true).catch((err: unknown) => {
        setError(err instanceof Error ? err.message : '推荐失败')
      })
    }, 300)
    return () => clearTimeout(timer)
  }, [expires, target?.batch_code, target?.dates_locked])

  async function onSubmit(event: FormEvent) {
    event.preventDefault()
    if (!target) return
    if (!window.confirm(`确认把 ${amount} ${target.base_unit} 上架到 ${location}?`)) return
    setPending(true)
    setError('')
    setOk('')
    try {
      await api('/api/putaways', {
        method: 'POST',
        body: JSON.stringify({
          batch_code: target.batch_code,
          qty: Number(amount),
          produced_on: produced,
          expires_on: expires,
          location_code: location,
        }),
      })
      setOk(`${target.batch_code} 已上架到 ${location}`)
      setTarget(null)
      setCode('')
      setAmount('')
      setLocation('')
      setReason('')
    } catch (err) {
      setError(err instanceof Error ? err.message : '上架失败')
    } finally {
      setPending(false)
    }
  }

  const remaining = target ? target.base_qty - target.put_away_qty : 0
  const ready =
    !!target &&
    remaining > 0 &&
    Number(amount) > 0 &&
    produced.length === 10 &&
    expires.length === 10 &&
    location !== '' &&
    (user?.current_warehouse_id ?? 0) > 0

  return (
    <Page title="上架">
      <Banner kind="error" text={error} />
      <Banner kind="ok" text={ok} />
      {(user?.current_warehouse_id ?? 0) === 0 && (
        <p className="banner error">请先在右上角选择当前作业仓库</p>
      )}
      <form className="filters" onSubmit={(event) => void onSearch(event)}>
        <Field label="批次码">
          <input value={code} onChange={(event) => setCode(event.target.value)} />
        </Field>
        <button type="submit" disabled={pending || !code.trim()}>
          查询
        </button>
      </form>
      {target && (
        <form className="panel" onSubmit={(event) => void onSubmit(event)}>
          <div className="cards">
            <div className="card"><span>商品</span><strong>{target.goods_name}</strong></div>
            <div className="card"><span>编码 / 条码</span><strong>{target.goods_code} / {target.barcode}</strong></div>
            <div className="card"><span>入库单</span><strong>{target.bill_code}</strong></div>
            <div className="card"><span>仓库</span><strong>{target.warehouse_name}</strong></div>
            <div className="card">
              <span>未上架</span>
              <strong>{qty(remaining)} {target.base_unit}</strong>
            </div>
          </div>
          <div className="filters">
            <Field label="生产日期">
              <input
                type="date"
                value={produced}
                readOnly={target.dates_locked}
                onChange={(event) => setProduced(event.target.value)}
              />
            </Field>
            <Field label="到期日">
              <input
                type="date"
                value={expires}
                readOnly={target.dates_locked}
                onChange={(event) => setExpires(event.target.value)}
              />
            </Field>
            <Field label="上架数量">
              <input value={amount} onChange={(event) => setAmount(event.target.value)} />
            </Field>
          </div>
          <Field label="库位">
            <select
              value={location}
              onChange={(event) => {
                setLocationTouched(true)
                setLocation(event.target.value)
              }}
            >
              <option value="">请选择</option>
              {spots.map((spot) => (
                <option key={spot.code} value={spot.code}>
                  {spot.code}
                  {spot.code === target.location_code ? '(推荐)' : ''}
                </option>
              ))}
            </select>
          </Field>
          <p className="muted">{reason || '没有可推荐的库位'}</p>
          {target.dates_locked && <p className="muted">这个批次码已经上过架,日期必须与之前一致。</p>}
          <button type="submit" disabled={!ready || pending}>
            {pending ? '提交中…' : '确认上架'}
          </button>
        </form>
      )}
    </Page>
  )
}

function roundQty(value: number) {
  return Math.round(value * 10000) / 10000
}

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.