Artifact d72f0828a471872c5463888ae25901a67a0bc464ef22ef6bb956589526242bb0:
- File
web/src/pages/Putaway.tsx
— part of check-in
[5ec5d40378]
at
2026-10-23 06:16:24
on branch trunk
— 上架推荐,并在 Web 控制台确认上架
- 同一批次优先集中;否则同品同到期日集中;新批次按到期先后放到相邻空库位 - 上架只在当前作业仓库进行,日期沿用该批次码已有的生产日期和到期日 - 采购入库单的批次码可进入上架页,没有库位时不推荐、也不能提交 (user: pluto size: 7468)
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 }