feat: 组织隔离(IRIS 单实例)与出料功能基础

本轮之前累积的未提交工作,一并固化:

- 组织隔离:同一份代码部署给不同部门只需改 config 的 ORG_DEPARTMENT 与
  MATERIAL_CATEGORY_PREFIX。过滤点在登录/人员列表/物料/MOM 出库单四处,
  全部服务端钉死,客户端传什么都放不大。
  ★ 物料必须用**前缀** LIKE,不能反推成 ILIKE '%IRIS%':MOM 里 LICA 的物料是
  `LICA/<中文>`,而本部门分类树里另有 `IRIS/成品/LICA/…`(本就属于本部门),
  前缀匹配天然区分得开。
- MOM 出库单只读查询(直连 MOM 库):不走 MOM 现成的 /outbound 接口 ——
  那个要 JWT + permission_required,且对非特权账号按 consumer_name 做行级
  隔离,服务账号只能拿到自己名下的单。分页必须两段式(先按单号 GROUP BY
  分页,再 IN 捞明细),对宽表直接分页会得到明细行数而不是单据数。
- 出料功能:产品 ↔ 出库单存档(product_outbounds)与任务 ↔ 出库明细
  (task_outbound_materials),供「这台设备对应 MOM 哪张单」的展示。
  ⚠️ 快照一律由后端拿 ID 去 MOM 现查,不接受前端传入,否则前端可伪造单据。
This commit is contained in:
2026-09-23 15:18:06 +08:00
parent edd43fec29
commit e45c97bd1f
22 changed files with 2000 additions and 48 deletions

View File

@ -0,0 +1,232 @@
/**
* 创建任务弹窗 —— 产品已由调用方(任务列表的某一行)确定,这里选工序/接收人/备注,
* 并可从 MOM 出库单里勾选出库物料一并挂上。
*
* ⚠️ 表单重置的 useEffect 必须写在 `if (!product) return null` **之前** ——
* 常驻挂载的 memo 组件先条件返回再调 Hook 会违反 Rules of Hooks产品从
* null 变非 null 时 Hook 数量错位直接崩TaskTreeViewer.tsx 有同样的血泪注释)。
*/
import { useEffect, useMemo, useState } from "react";
import { Loader2, Package, Truck } from "lucide-react";
import { Modal } from "../TaskTree/TaskTreeViewer";
import { useToast } from "../ui/Toast";
import MomOutboundPicker from "./MomOutboundPicker";
import { createTask } from "../../services/taskApi";
import { listUsers, type UserOption } from "../../services/userApi";
import { extractErrorMessage } from "../../utils/errorMessage";
import { taskOptionsFor } from "../../constants/task";
import type { MomOutboundOrder } from "../../types/api";
import type { ProductResponse } from "../../types/admin";
interface Props {
open: boolean;
onClose: () => void;
/** 目标产品 —— 由任务列表里点的那一行确定 */
product: ProductResponse | null;
onCreated: () => void;
}
const INPUT_CLS =
"w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100 disabled:bg-gray-100 disabled:text-gray-400";
export default function CreateTaskDialog({ open, onClose, product, onCreated }: Props) {
const { toast } = useToast();
const [users, setUsers] = useState<UserOption[]>([]);
const [taskName, setTaskName] = useState("");
const [assigneeId, setAssigneeId] = useState("");
const [remark, setRemark] = useState("");
const [pickedOrders, setPickedOrders] = useState<MomOutboundOrder[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
// ⚠️ 必须在 `if (!product) return null` 之前
useEffect(() => {
if (!open) return;
setTaskName("");
setAssigneeId("");
setRemark("");
setPickedOrders([]);
setPickerOpen(false);
}, [open]);
useEffect(() => {
if (!open) return;
listUsers()
.then(setUsers)
.catch(() => toast("加载人员列表失败", "error"));
}, [open, toast]);
// 可选工序随产品的生命周期阶段/宏观状态变化
const stepOptions = useMemo(
() => taskOptionsFor(product?.lifecycle_phase, true, product?.overall_status),
[product?.lifecycle_phase, product?.overall_status],
);
if (!product) return null;
const pickedLineCount = pickedOrders.reduce((s, o) => s + o.lines.length, 0);
async function handleSubmit() {
if (!product) return;
if (!taskName) {
toast("请选择工序", "error");
return;
}
setSubmitting(true);
try {
await createTask({
product_id: product.id,
task_name: taskName,
assignee_id: assigneeId || null,
remark: remark.trim() || undefined,
mom_line_ids: pickedOrders.flatMap((o) => o.lines.map((l) => l.line_id)),
});
toast("任务已创建", "success");
onClose();
onCreated();
} catch (err) {
toast(extractErrorMessage(err, "创建任务失败"), "error");
} finally {
setSubmitting(false);
}
}
return (
<>
<Modal
open={open}
onClose={onClose}
title="创建任务"
widthClass="max-w-lg"
bodyClassName="max-h-[85vh] overflow-y-auto"
>
{/* ---- 产品信息(只读) ---- */}
<div className="mb-4 rounded-lg bg-blue-50 px-3 py-2.5 text-sm text-blue-700">
<p className="flex items-center gap-1.5 font-medium">
<Package className="h-4 w-4" />
<span className="font-mono">{product.serial_number}</span>
</p>
<p className="mt-0.5 text-xs text-blue-500">
{product.material_name || "—"}
{product.overall_status ? ` · 当前状态 ${product.overall_status}` : ""}
</p>
</div>
{/* ---- 表单 ---- */}
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-gray-600">
<span className="text-red-500">*</span>
</label>
<select value={taskName} onChange={(e) => setTaskName(e.target.value)} className={INPUT_CLS}>
<option value=""></option>
{stepOptions.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)} className={INPUT_CLS}>
<option value=""></option>
{users.map((u) => (
<option key={u.username} value={u.username}>
{u.full_name}{u.username}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-600"></label>
<textarea
value={remark}
onChange={(e) => setRemark(e.target.value)}
rows={2}
maxLength={2000}
placeholder="初始描述 / 交接备注"
className={`${INPUT_CLS} resize-none`}
/>
</div>
{/* ---- 出库物料 ---- */}
<div>
<label className="mb-1 block text-xs font-medium text-gray-600">
<span className="text-gray-400"></span>
</label>
{pickedOrders.length === 0 ? (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-gray-300 px-3 py-2.5 text-sm text-gray-500 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
>
<Truck className="h-4 w-4" />
MOM
</button>
) : (
<div className="space-y-1.5">
{pickedOrders.map((o) => (
<div key={o.outbound_no}
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-xs">
<span className="font-mono font-medium text-gray-800">{o.outbound_no}</span>
<span className="text-gray-500">{o.line_count} </span>
<button
type="button"
onClick={() => setPickedOrders((prev) => prev.filter((x) => x.outbound_no !== o.outbound_no))}
className="ml-auto rounded px-1.5 text-gray-400 hover:bg-white hover:text-red-500"
title="移除"
>
</button>
</div>
))}
<button
type="button"
onClick={() => setPickerOpen(true)}
className="text-xs font-medium text-blue-600 hover:text-blue-700"
>
+
</button>
</div>
)}
</div>
</div>
{/* ---- 底部操作 ---- */}
<div className="mt-5 flex items-center justify-between border-t border-gray-100 pt-3">
<span className="text-xs text-gray-400">
{pickedLineCount > 0 ? `将挂载 ${pickedLineCount} 条物料` : ""}
</span>
<div className="flex gap-2">
<button onClick={onClose} disabled={submitting}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
</button>
<button onClick={handleSubmit} disabled={submitting || !taskName}
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
</button>
</div>
</div>
</Modal>
{/* 选择器叠在创建弹窗之上 */}
<MomOutboundPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onConfirm={(_ids, orders) => {
setPickedOrders((prev) => {
const seen = new Set(prev.map((o) => o.outbound_no));
return [...prev, ...orders.filter((o) => !seen.has(o.outbound_no))];
});
setPickerOpen(false);
}}
existingOrderNos={pickedOrders.map((o) => o.outbound_no)}
/>
</>
);
}

View File

@ -0,0 +1,412 @@
/**
* MOM 出库单选择器 — 从 MOM 搜索出库单,**按整张单**勾选
*
* 粒度说明勾选的是单据outbound_no确认时把该单**全部明细行 ID** 一起提交
* (后端按明细行落快照)。业务上确认过「单据里的物料都是相关的」,不存在无关物料。
*
* 过滤维度(都是**收窄**,可见范围由后端强制,前端传什么都放不大):
* · 关键词 —— 出库单号 / 物料名称 / 规格型号 / SKU / 领用人
* · 出库时间区间
* · 领用人 —— **打开时默认筛成当前账号本人**(理由见 open 时的那个 effect
*
* 已经挂过的单据会显示「已挂载」并禁止再选 —— 后端虽然有唯一约束兜底,但让用户
* 在这里就能看出来更省事。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { DatePicker, Modal, Select } from "antd";
import { ChevronDown, ChevronRight, Loader2, Package, Search } from "lucide-react";
import { useAuth } from "../../contexts/AuthContext";
import { useToast } from "../ui/Toast";
import {
listMomOutboundConsumers,
searchMomOutbounds,
} from "../../services/momApi";
import { extractErrorMessage } from "../../utils/errorMessage";
import type { MomOutboundOrder } from "../../types/api";
const { RangePicker } = DatePicker;
interface Props {
open: boolean;
onClose: () => void;
/** 确认选择:回传选中的全部明细行 ID 与选中的单据(供调用方展示) */
onConfirm: (momLineIds: number[], orders: MomOutboundOrder[]) => void;
submitting?: boolean;
/** 该任务/产品已挂载的出库单号 —— 这些单在列表里标记「已挂载」且不可再选 */
existingOrderNos?: string[];
}
const PAGE_SIZE = 20;
/** 把当前筛选条件拼成一个字符串,用作「筛选是否变了」的比较键 */
function makeFilterKey(
kw: string, consumer?: string, start?: string, end?: string,
): string {
return [kw.trim(), consumer ?? "", start ?? "", end ?? ""].join("|");
}
const EMPTY_FILTER_KEY = makeFilterKey("");
/** 出库时间 → 本地可读格式 */
function formatTime(iso: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
export default function MomOutboundPicker({
open, onClose, onConfirm, submitting = false, existingOrderNos = [],
}: Props) {
const { toast } = useToast();
const { user } = useAuth();
/** 当前登录账号的姓名 —— 打开时默认拿它去筛领用人(未登录兜底为空串 = 不筛) */
const accountName = (user?.display_name ?? "").trim();
// ---- 筛选条件 ----
const [keyword, setKeyword] = useState("");
// 只留 YYYY-MM-DD 字符串RangePicker 走非受控 + key 重挂载来重置,
// 这样不必引入 dayjs 的类型(它不是本项目的直接依赖)
const [range, setRange] = useState<[string, string] | null>(null);
const [pickerKey, setPickerKey] = useState(0);
const [consumer, setConsumer] = useState<string | undefined>();
// ---- 下拉数据 ----
const [consumerOptions, setConsumerOptions] = useState<string[]>([]);
// ---- 结果 ----
const [orders, setOrders] = useState<MomOutboundOrder[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(new Set());
const existing = new Set(existingOrderNos);
const lastKeyRef = useRef<string>(EMPTY_FILTER_KEY);
const [start, end] = range ?? ["", ""];
const load = useCallback(async (
kw: string, c?: string, sd?: string, ed?: string,
) => {
setLoading(true);
try {
const res = await searchMomOutbounds({
keyword: kw.trim() || undefined,
consumer: c || undefined,
start_date: sd || undefined,
end_date: ed || undefined,
limit: PAGE_SIZE,
});
setOrders(res.orders);
setTotal(res.total);
} catch (err) {
toast(extractErrorMessage(err, "加载 MOM 出库单失败"), "error");
setOrders([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [toast]);
// ---- 打开时重置 + 拉一次可选领用人,再**带着默认领用人**查一次 ----
//
// 为什么两件事合在一个 effect 里:默认领用人就是当前账号本人,而「本人在不在
// 可选列表里」得先拿到列表才知道(没领过料的人本来就不该被筛 —— 筛了会得到
// 一屏空白,用户还以为系统坏了)。拆成两个 effect 的话只能先空筛查一次、拿到
// 名单再改条件查第二次:既多打一次请求,还会先闪一屏别人的单据再被抽走。
//
// 拿不到名单(接口挂了)就退回「全部领用人」,不能卡住整个选择器。
useEffect(() => {
if (!open) return;
setKeyword("");
setRange(null);
setPickerKey((k) => k + 1);
setExpanded(new Set());
setSelected(new Set());
let cancelled = false;
(async () => {
let names: string[] = [];
try {
names = await listMomOutboundConsumers();
} catch {
names = [];
}
if (cancelled) return;
setConsumerOptions(names);
// MOM 的 consumer_name 存的就是人名,与登录态的 display_name 同一口径
// sys_user.username 的「姓名/账号」前半段)。
const mine = accountName && names.includes(accountName) ? accountName : undefined;
setConsumer(mine);
// 先写 lastKeyRef 再 setState防抖 effect 里靠它去重,不写就会在 300ms 后
// 用同一组条件再查一次。
lastKeyRef.current = makeFilterKey("", mine);
load("", mine);
})();
return () => {
cancelled = true;
};
}, [open, load, accountName]);
// ---- 筛选变化 → 防抖搜索300ms。靠 filterKey 去重,避免重置时多打一次 ----
const filterKey = makeFilterKey(keyword, consumer, start, end);
useEffect(() => {
if (!open) return;
if (filterKey === lastKeyRef.current) return;
const timer = setTimeout(() => {
lastKeyRef.current = filterKey;
load(keyword, consumer, start, end);
}, 300);
return () => clearTimeout(timer);
}, [filterKey, open, load, keyword, consumer, start, end]);
function toggleOrder(no: string) {
if (existing.has(no)) return;
setSelected((prev) => {
const next = new Set(prev);
if (next.has(no)) next.delete(no); else next.add(no);
return next;
});
}
function toggleExpand(no: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(no)) next.delete(no); else next.add(no);
return next;
});
}
/**
* 勾选里有没有**不是自己领的**单。
*
* 判据出库单的领用人MOM 侧自由填写的姓名)≠ 当前登录人姓名。
* ⚠️ 这是**提示**不是权限 —— 料的归属是设备不是人,代挂是合理操作
* (测试替生产补挂、库管代录都会发生)。拦一道只是防手滑勾错别人的单。
*/
function proxyOrders(picked: MomOutboundOrder[]) {
const myName = (user?.display_name ?? "").trim();
if (!myName) return [];
return picked.filter((o) => o.consumer_name && o.consumer_name !== myName);
}
function handleConfirm() {
const picked = orders.filter((o) => selected.has(o.outbound_no));
const proxy = proxyOrders(picked);
const submit = () =>
onConfirm(picked.flatMap((o) => o.lines.map((l) => l.line_id)), picked);
if (proxy.length === 0) return submit();
// 提交前拦一道,用户还能取消回去改勾选
const who = [...new Set(proxy.map((o) => o.consumer_name).filter(Boolean))].join("、");
Modal.confirm({
title: "确认代挂",
content: `选中里有 ${proxy.length} 张不是你自己领的单(领用人:${who})。`
+ "挂上去会记在你名下(挂载人),确认继续?",
okText: "确认代挂",
cancelText: "再看看",
onOk: submit,
});
}
const selectedLineCount = orders
.filter((o) => selected.has(o.outbound_no))
.reduce((sum, o) => sum + o.lines.length, 0);
const hasFilter = !!(keyword.trim() || consumer || range);
return (
// ⚠️ 这里用 antd 的 Modal**不是**任务域那套自写 Modal。
// 本选择器既被自写 Modal 打开(创建任务),也被 antd Modal 打开(创建产品)。
// antd 弹窗走 portal 且默认 z-index 1000自写 Modal 的 z-50 会被它整个
// 盖住 —— 只从下面露出一截列表,看起来像「内容被后置」。
<Modal
open={open}
onCancel={onClose}
title="选择 MOM 出库物料"
width={920}
footer={null}
destroyOnHidden
>
{/* ★ 只有中间的**单据列表**滚动,搜索筛选与底部操作条固定。
整块一起滚的话,滚到下面就看不到「共 N 张单据」、也够不着「确认选择」——
用户会以为没得选(实测反馈就是这么来的)。
用 flex 列布局 + min-h-0 让中间那块真正可滚min-h-0 不能省:
flex 子项默认 min-height:auto会撑开容器导致整页滚动。 */}
<div className="flex max-h-[72vh] flex-col">
{/* ---- 搜索 ---- */}
<div className="relative mb-2 shrink-0">
<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="搜索出库单号 / 物料名称 / 规格型号 / SKU / 领用人"
className="w-full rounded-lg border border-gray-200 py-2 pl-9 pr-8 text-sm focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
{loading && (
<Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-blue-500" />
)}
</div>
{/* ---- 筛选行 ---- */}
<div className="mb-3 flex shrink-0 flex-wrap items-center gap-2">
<RangePicker
key={pickerKey}
size="small"
onChange={(_: unknown, strings: [string, string]) => {
const [s, e] = strings;
setRange(s && e ? [s, e] : null);
}}
placeholder={["开始日期", "结束日期"]}
/>
<Select
size="small"
allowClear
showSearch
placeholder="全部领用人"
style={{ minWidth: 130 }}
value={consumer}
onChange={setConsumer}
options={consumerOptions.map((n) => ({ value: n, label: n }))}
/>
{hasFilter && (
<button
onClick={() => {
setKeyword(""); setRange(null); setPickerKey((k) => k + 1);
setConsumer(undefined);
}}
className="text-xs text-gray-400 hover:text-gray-600"
>
</button>
)}
<span className="ml-auto text-xs text-gray-400">
<span className="font-semibold text-gray-600">{total}</span>
</span>
</div>
{/* ---- 单据列表(**只有这块滚动**---- */}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{!loading && orders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-14 text-gray-400">
<Package className="mb-2 h-10 w-10" />
<p className="text-sm">{hasFilter ? "没有匹配的出库单" : "没有可选的出库单"}</p>
{hasFilter && (
<p className="mt-1 text-xs text-gray-300"></p>
)}
</div>
) : (
<div className="space-y-1.5">
{orders.map((o) => {
const isExisting = existing.has(o.outbound_no);
const isSelected = selected.has(o.outbound_no);
const isOpen = expanded.has(o.outbound_no);
// 整行可点 = 勾选/取消(展开按钮自己 stopPropagation
// 复选框只是状态的「显示」,不再是唯一入口 —— 所以它
// readOnly + pointer-events-none点它的事件穿透到整行上
// 避免 onChange 与整行 onClick 各切一次、等于没切。
return (
<div
key={o.outbound_no}
onClick={() => toggleOrder(o.outbound_no)}
className={`rounded-lg border px-2.5 py-1.5 transition-colors ${
isExisting ? "cursor-not-allowed border-gray-200 bg-gray-50"
: isSelected ? "cursor-pointer border-blue-300 bg-blue-50"
: "cursor-pointer border-gray-100 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{/* 单行放完:勾选 / 单号 / 类型 / 领用+经办+条数 / 时间 / 展开 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
disabled={isExisting}
readOnly
tabIndex={-1}
className="pointer-events-none h-4 w-4 shrink-0"
/>
<span className={`shrink-0 font-mono text-[13px] font-medium ${isExisting ? "text-gray-400" : "text-gray-800"}`}>
{o.outbound_no}
</span>
{isExisting && (
<span className="shrink-0 rounded-full bg-gray-200 px-1.5 py-0.5 text-[10px] font-bold text-gray-600">
</span>
)}
{o.outbound_type && (
<span className="shrink-0 rounded-full bg-purple-100 px-1.5 py-0.5 text-[10px] font-medium text-purple-700">
{/* 中文名由后端按 MOM 码表下发;万一没下发就退回原始码,
免得整块徽标凭空消失 */}
{o.outbound_type_label || o.outbound_type}
</span>
)}
<span className="min-w-0 truncate text-xs text-gray-500">
{o.consumer_name && <> {o.consumer_name}</>}
{o.operator_name && <span className="ml-2 text-gray-400"> {o.operator_name}</span>}
<span className="ml-2 text-gray-400">
<span className="font-medium text-gray-600">{o.line_count}</span>
{o.total_quantity != null && <> · {o.total_quantity}</>}
</span>
</span>
<span className="ml-auto shrink-0 text-xs text-gray-400">
{formatTime(o.outbound_time)}
</span>
{/* 看物料明细的按钮 —— 带边框才看得出是个按钮,别只给个裸图标 */}
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand(o.outbound_no);
}}
className="flex shrink-0 items-center gap-0.5 rounded-md border border-gray-200 p-1 text-gray-500 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:text-gray-700"
title={isOpen ? "收起物料明细" : `查看物料明细(${o.line_count} 条)`}
>
<Package className="h-4 w-4" />
{isOpen ? <ChevronDown className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
</button>
</div>
{isOpen && (
<div className="mt-1.5 space-y-0.5 border-t border-gray-100 pt-1.5 pl-6">
{o.lines.map((l) => (
<div key={l.line_id} className="flex flex-wrap items-baseline gap-x-3 text-xs">
<span className="font-medium text-gray-700">{l.material_name || "(未命名物料)"}</span>
{l.spec_model && <span className="text-gray-400">{l.spec_model}</span>}
<span className="text-gray-500">× {l.quantity}</span>
{l.warehouse_location && <span className="text-gray-400"> {l.warehouse_location}</span>}
{l.request_no && <span className="text-gray-400"> {l.request_no}</span>}
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* ---- 底部操作(固定,不随列表滚动)---- */}
<div className="mt-3 flex shrink-0 items-center justify-between border-t border-gray-100 pt-3">
<span className="text-xs text-gray-500">
<span className="font-semibold text-gray-700">{selected.size}</span>
<span className="font-semibold text-gray-700">{selectedLineCount}</span>
</span>
<div className="flex gap-2">
<button onClick={onClose} disabled={submitting}
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50">
</button>
<button onClick={handleConfirm} disabled={submitting || selected.size === 0}
className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
{submitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
</button>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -3,6 +3,7 @@ import { Loader2, AlertCircle } from "lucide-react";
import type { ProductScanResponse } from "../../types/api";
import ProductCard from "./ProductCard";
import TaskListCard from "./TaskListCard";
import OutboundRecordsCard from "./OutboundRecordsCard";
interface QueryResultProps {
loading: boolean;
@ -33,6 +34,9 @@ export default function QueryResult({ loading, error, product }: QueryResultProp
return (
<div className="space-y-4">
<ProductCard product={product} />
{/* 出库单据 —— 卡片按 productId 自取数据(与「产品管理 → 编辑产品」共用
同一个组件)。无记录时显示空态 + 「追加出库单」入口。 */}
<OutboundRecordsCard productId={product.id} />
<TaskListCard tasks={product.task_tree || product.top_level_tasks} />
</div>
);

View File

@ -3,7 +3,7 @@ import { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import {
Search, Loader2, Package, ChevronDown, ChevronRight,
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns,
Warehouse, GitBranch, X, ArrowUp, ArrowDown, ArrowUpDown, Filter, Columns, Plus,
} from "lucide-react";
import { Tooltip, Popover, Checkbox, Input, Button } from "antd";
import api from "../../services/api";
@ -14,6 +14,7 @@ import {
import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
import { Modal, ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
import CreateTaskDialog from "../../components/admin/CreateTaskDialog";
import type { ProductResponse } from "../../types/admin";
import type { ProductScanResponse } from "../../types/api";
import { useToast } from "../../components/ui/Toast";
@ -92,6 +93,9 @@ export default function AdminTasksPage() {
const [submitting, setSubmitting] = useState(false);
const [qrSerial, setQrSerial] = useState<string | null>(null); // 🔧 QR弹窗
// 🔧 创建任务弹窗 —— 产品由所点的那一行确定,弹窗里再选工序/接收人/出库物料
const [createTarget, setCreateTarget] = useState<ProductResponse | null>(null);
// ---- 列配置10列----
const columns: ColumnDef[] = [
{
@ -207,10 +211,17 @@ export default function AdminTasksPage() {
render: (p) => {
const isTreeLoading = treeLoading[p.serial_number];
return (
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
</button>
<div className="flex items-center gap-1.5">
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
</button>
{/* 创建任务:产品由本行确定,弹窗里再选工序/接收人/出库物料 */}
<button onClick={() => setCreateTarget(p)} className="flex items-center gap-1 rounded border border-emerald-200 px-2.5 py-1 text-xs font-medium text-emerald-600 hover:bg-emerald-50 transition-colors">
<Plus className="h-3 w-3" />
</button>
</div>
);
},
},
@ -924,6 +935,14 @@ export default function AdminTasksPage() {
onClose={() => setModalTarget(null)}
onSubmit={handleTransfer}
/>
{/* 🔧 创建任务弹窗(含从 MOM 出库单选物料) */}
<CreateTaskDialog
open={createTarget !== null}
product={createTarget}
onClose={() => setCreateTarget(null)}
onCreated={() => { loadProducts(keyword); }}
/>
</div>
);
}

View File

@ -1,9 +1,11 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Modal, Collapse, Table, Input, Button, Space, Tag, App, Spin, Empty } from "antd";
import { SearchOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
import { SearchOutlined, PlusOutlined, MinusOutlined, TruckOutlined } from "@ant-design/icons";
import api from "../../services/api";
import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
import MomOutboundPicker from "../../components/admin/MomOutboundPicker";
import { extractErrorMessage } from "../../utils/errorMessage";
import type { MomOutboundOrder } from "../../types/api";
// ============================================================
// 类型
@ -51,6 +53,10 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
const [submitting, setSubmitting] = useState(false);
const [createdSn, setCreatedSn] = useState<string | null>(null);
// ---- 建档时挂钩的 MOM 出库单(可选) ----
const [pickedOrders, setPickedOrders] = useState<MomOutboundOrder[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
// ---- 初始化 ----
useEffect(() => {
if (open) {
@ -62,6 +68,8 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
setCreatedSn(null);
groupCache.current.clear();
groupLoadingMap.current.clear();
setPickedOrders([]);
setPickerOpen(false);
loadSummary();
}
}, [open]);
@ -182,6 +190,10 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
material_type: selected.material_type,
external_serial: externalSerial.trim() || null,
order_no: orderNo.trim() || null,
// 挂钩的出库单:用户是按**整张单**勾选的,所以提交该单全部明细行 ID
// 后端归并回单据后写进 product_outboundssource=manual
// 不选就是空数组,后端不挂载。
mom_line_ids: pickedOrders.flatMap((o) => o.lines.map((l) => l.line_id)),
});
setCreatedSn(data.serial_number);
onCreated();
@ -332,6 +344,7 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
// ============================================================
return (
<>
<Modal
title="创建产品"
open={open}
@ -493,6 +506,43 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
/>
</div>
{/* MOM 出库单挂钩(选填) */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
<span className="text-xs text-gray-400">()</span>
</label>
{pickedOrders.length === 0 ? (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-gray-300 px-3 py-2.5 text-sm text-gray-500 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
>
<TruckOutlined />
MOM
</button>
) : (
<div className="space-y-1.5">
{pickedOrders.map((o) => (
<div key={o.outbound_no}
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-2.5 py-1.5 text-xs">
<span className="font-mono font-medium text-gray-800">{o.outbound_no}</span>
<span className="text-gray-500">{o.line_count} </span>
<Button
type="text" size="small" danger
className="ml-auto"
onClick={() => setPickedOrders((prev) => prev.filter((x) => x.outbound_no !== o.outbound_no))}
>
</Button>
</div>
))}
<Button type="link" size="small" onClick={() => setPickerOpen(true)}>
+
</Button>
</div>
)}
</div>
{/* 提交 */}
<Button
type="primary"
@ -507,5 +557,20 @@ export default function CreateProductDialog({ open, onClose, onCreated }: Props)
</div>
)}
</Modal>
{/* MOM 出库单选择器 —— 叠在创建产品弹窗之上 */}
<MomOutboundPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onConfirm={(_ids, orders) => {
setPickedOrders((prev) => {
const seen = new Set(prev.map((o) => o.outbound_no));
return [...prev, ...orders.filter((o) => !seen.has(o.outbound_no))];
});
setPickerOpen(false);
}}
existingOrderNos={pickedOrders.map((o) => o.outbound_no)}
/>
</>
);
}

View File

@ -0,0 +1,48 @@
/** MOM 出库单相关接口 —— 任务挂载出库物料时搜索选择用 */
import api from "./api";
import type { MomOutboundSearchResponse } from "../types/api";
export interface MomOutboundSearchParams {
/** 出库单号 / 物料名称 / 规格型号 / SKU / 领用人,任一命中 */
keyword?: string;
/** YYYY-MM-DD含当日 */
start_date?: string;
/** YYYY-MM-DD含当日 */
end_date?: string;
/** 按领用人(中文名)过滤 */
consumer?: string;
/** 跳过**单据数**(不是明细行数) */
skip?: number;
/** 返回**单据数**,后端上限 100 */
limit?: number;
}
/**
* 搜索 MOM 出库单(按单据分页,带回每张单的明细)。
* 对应后端 GET /api/v1/mom-outbounds
*
* ⚠️ 可见范围(公司隔离 + 跨部门例外)由后端服务层钉死,本接口**没有任何**
* 能放大范围的参数 —— 界面筛选只能收窄。
*/
export async function searchMomOutbounds(
params: MomOutboundSearchParams = {},
): Promise<MomOutboundSearchResponse> {
const { data } = await api.get<MomOutboundSearchResponse>("/mom-outbounds", { params });
return data;
}
/**
* 本部门出库单里出现过的**领用人姓名**(去重,按出现次数降序)。
* 对应后端 GET /api/v1/mom-outbounds/consumers
*
* ⚠️ 后端**已按权限范围过滤** —— 下拉里不会出现用户看不到的人名。
*/
export async function listMomOutboundConsumers(): Promise<string[]> {
const { data } = await api.get<string[]>("/mom-outbounds/consumers");
return data;
}
// 注:原先这里有 addTaskOutboundMaterials / removeTaskOutboundMaterial
// 两个**任务级**的读写函数。物料已统一为**设备级**,任务级的三个端点连同
// 实现一起删除 —— 挂载/查看/删除/报废一律走 productApi 里的
// mountProductOutboundMaterials / removeProductOutboundMaterial。

View File

@ -26,6 +26,19 @@ export interface TaskCompletePayload {
remark: string | null;
}
export interface TaskCreatePayload {
product_id: string;
task_name: string;
assignee_id?: string | null;
remark?: string;
/**
* 创建时一并挂载的 MOM 出库**明细行** IDtrans_outbound.id
* 前端按整张出库单勾选,提交时把该单全部明细 ID 带过来。
* 不传 = 不挂载。
*/
mom_line_ids?: number[];
}
// ============================================================
// API 方法
// ============================================================
@ -43,6 +56,16 @@ export async function listTasks(productId?: string): Promise<TaskListResponse> {
return data;
}
/**
* 创建任务 — 对应后端 POST /api/v1/tasks/
* payload.mom_line_ids 非空时,后端会在**同一事务**里把对应的 MOM 出库明细
* 挂到新任务上,不存在「任务建好了但物料没挂上」的中间态。
*/
export async function createTask(payload: TaskCreatePayload): Promise<TaskResponse> {
const { data } = await api.post<TaskResponse>("/tasks/", payload);
return data;
}
/** 创建子任务 */
export async function createSubtask(
parentTaskId: string,

View File

@ -0,0 +1,21 @@
/** 用户列表 —— 对接 MOM sys_user只返回本部门人员 */
import api from "./api";
export interface UserOption {
id: string;
/** 登录账号,即任务里的 assignee_id 口径 */
username: string;
/** 中文姓名 */
full_name: string;
department: string;
}
/**
* 获取本部门人员列表。
* 对应后端 GET /api/v1/users/ —— 部门隔离由服务端按 ORG_DEPARTMENT 钉死,
* 客户端传什么都没用。
*/
export async function listUsers(keyword = "", limit = 200): Promise<UserOption[]> {
const { data } = await api.get<UserOption[]>("/users/", { params: { keyword, limit } });
return data;
}