feat: Web 文件上传、免登录访客隔离与锐扬试排修复

- Web 端支持本机工程数据文件上传并自动落位项目目录
- 免登录云部署按浏览器访客 ID + HttpOnly Cookie 隔离项目/会话/个人世界
- 修复设置页偏好接口返回结构导致的 Object.keys 白屏
- 修复工程目录试排 0 虚拟产线:非排产 sheet 跳过、物料 id 冲突、试排走 PoolEngine
- 补充黄金测试:文件上传、访客隔离、trial 试排、目录跳过、物料 id
This commit is contained in:
z.zhang 2026-08-11 19:01:05 +08:00
parent 3997809acb
commit ef7256f12c
20 changed files with 651 additions and 40 deletions

View File

@ -94,6 +94,10 @@ npm run build:desktop # Electron 安装包 → apps/desktop/release
3. 「试排一版交期优先」/ 「发布这个版本」
4. `pytest tests/golden -q`(当前期望全绿,见 CHANGELOG 最新验证数)
Web 端读取本机数据文件:在项目面板「文件」区点「上传」,选择本机的
`.xlsx/.xlsm/.csv/.txt/.sql`(可多选);上传后自动作为该项目的工程目录,
对话中说「分析一下数据文件」即可走同一套目录分析与排产链路。
完整旅程说明:[docs/product/journeys.md](docs/product/journeys.md)
## 大模型接入

View File

@ -30,6 +30,24 @@ export function getClientMode(): ClientMode {
return window.apsDesktop?.mode === 'desktop' ? 'desktop' : 'web';
}
const ANONYMOUS_VISITOR_KEY = 'aps.visitor.id.v1';
let memoryVisitorId: string | null = null;
function ensureVisitorId(): string {
try {
const existing = window.localStorage.getItem(ANONYMOUS_VISITOR_KEY);
if (existing && existing.length >= 8) return existing;
const next = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
? crypto.randomUUID().replace(/-/g, '')
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
window.localStorage.setItem(ANONYMOUS_VISITOR_KEY, next);
return next;
} catch {
memoryVisitorId ??= `mem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return memoryVisitorId;
}
}
let desktopDeviceId: Promise<string> | null = null;
async function getDesktopDeviceId(): Promise<string> {
@ -43,6 +61,8 @@ export async function clientFetch(input: RequestInfo | URL, init: RequestInit =
if (getClientMode() === 'desktop') {
headers.set('X-APS-Client', 'desktop');
headers.set('X-APS-Device-ID', await getDesktopDeviceId());
} else {
headers.set('X-APS-Visitor-ID', ensureVisitorId());
}
return window.fetch(input, { ...init, headers, credentials: 'include' });
}
@ -631,7 +651,12 @@ export const fetchGoldenTests = (run = false) =>
// LLM Provider 状态
export const fetchLlmSettings = () => getJson<LlmSettings>('/api/settings/llm');
export const fetchPreferenceExplain = (projectId?: string) =>
getJson<PreferenceExplain>(`/api/settings/preferences${projectId ? `?project_id=${encodeURIComponent(projectId)}` : ''}`);
getJson<{ explain: PreferenceExplain; projectId?: string | null; maxSamples?: number }>(
`/api/settings/preferences${projectId ? `?project_id=${encodeURIComponent(projectId)}` : ''}`,
).then(r => {
if (!r.explain) throw new ApiError(200, 'preferences explain 缺失');
return { ...r.explain, scores: r.explain.scores ?? {} };
});
export async function resetPreferences(projectId?: string) {
const resp = await apiFetch('/api/settings/preferences/reset', {
@ -884,6 +909,24 @@ export const createWorkspaceFile = (file: {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(file),
});
export const uploadProjectFiles = (projectId: string, files: File[]) => {
const form = new FormData();
for (const file of files) form.append('files', file, file.name);
return apiFetch(`/api/projects/${encodeURIComponent(projectId)}/files/upload`, {
method: 'POST',
body: form,
}).then(async response => {
if (!response.ok) throw new ApiError(response.status, await response.text());
return response.json() as Promise<{
message?: string;
saved?: string[];
errors?: { name: string; error: string }[];
workDir?: string;
workspace?: unknown;
}>;
});
};
export const deleteWorkspaceFile = (fileId: string) =>
workspaceMutation(`/api/project-files/${encodeURIComponent(fileId)}`, { method: 'DELETE' });

View File

@ -789,9 +789,9 @@ function PersonalView() {
<div className="gov-kv">{explain.reason}(有效样本 {explain.sampleCount} / 总样本 {explain.totalCount})</div>
<div className="gov-kv">
得分明细:
{Object.keys(explain.scores).length === 0
{Object.keys(explain.scores ?? {}).length === 0
? ' 暂无历史信号'
: Object.entries(explain.scores)
: Object.entries(explain.scores ?? {})
.map(([k, v]) => `${k} ${v}分`)
.join(' · ')}
</div>

View File

@ -21,6 +21,7 @@ import {
deleteWorkspaceSession,
renameWorkspaceProject,
renameWorkspaceSession,
uploadProjectFiles,
} from '../api/client';
const STATUS_LABEL: Record<string, string> = {
@ -183,6 +184,9 @@ export default function ProjectPanel(props: {
const [projectDlgOpen, setProjectDlgOpen] = useState(false);
const [confirmDlg, setConfirmDlg] = useState<ConfirmKind | null>(null);
const [memberProject, setMemberProject] = useState<Project | null>(null);
const uploadRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [uploadNote, setUploadNote] = useState('');
const activeProject = useMemo(
() => data.projects.find(p => p.id === data.activeProjectId) ?? null,
@ -225,6 +229,25 @@ export default function ProjectPanel(props: {
setNewTaskOpen(false);
};
const onPickUpload = async (files: FileList | null) => {
const picked = Array.from(files ?? []);
if (!picked.length || !activeProject) return;
setUploading(true);
setUploadNote('');
try {
const res = await uploadProjectFiles(activeProject.id, picked);
const failed = (res.errors ?? []).filter(item => item.error);
setUploadNote(failed.length
? `${res.message ?? '上传完成'};${failed.length} 个失败`
: (res.message ?? `已上传 ${res.saved?.length ?? 0} 个文件`));
props.onRefresh();
} catch (err) {
setUploadNote(err instanceof Error ? err.message : '上传失败');
} finally {
setUploading(false);
}
};
useEffect(() => {
const onMenu = (e: Event) => {
const action = (e as CustomEvent<{ action?: string }>).detail?.action;
@ -475,12 +498,36 @@ export default function ProjectPanel(props: {
<div className="sidebar-section sidebar-file-section">
<div className="sidebar-section-head">
<span>文件</span>
<button
type="button"
className="text-btn"
onClick={() => activeProject && setNameDlg({ mode: 'new-file', projectId: activeProject.id })}
>登记</button>
<input
ref={uploadRef}
type="file"
multiple
accept=".xlsx,.xlsm,.csv,.txt,.sql"
hidden
onChange={e => { void onPickUpload(e.target.files); e.target.value = ''; }}
/>
<div className="sidebar-file-actions">
<button
type="button"
className="text-btn"
disabled={uploading}
onClick={() => uploadRef.current?.click()}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="m17 8-5-5-5 5" /><path d="M12 3v12" />
</svg>
上传
</button>
<button
type="button"
className="text-btn"
onClick={() => activeProject && setNameDlg({ mode: 'new-file', projectId: activeProject.id })}
>登记</button>
</div>
</div>
{uploadNote && <div className="sidebar-upload-note">{uploadNote}</div>}
<div className="sidebar-list">
{projectFiles.length === 0 && <div className="sidebar-empty">暂无文件</div>}
{projectFiles.map(f => (

View File

@ -906,6 +906,11 @@ html.aps-desktop .app-menubar-spacer,
.text-btn { border: none; background: transparent; color: var(--accent-deep); font-size: 12px;
cursor: pointer; font-weight: 600; padding: 3px 8px; border-radius: 8px; }
.text-btn:hover { background: var(--accent-soft); }
.text-btn:disabled { opacity: .45; cursor: not-allowed; }
.text-btn svg { flex: 0 0 auto; }
.sidebar-file-actions { display: inline-flex; align-items: center; gap: 4px; }
.sidebar-upload-note { font-size: 11px; color: var(--accent-deep); line-height: 1.35;
padding: 2px 0 6px; word-break: break-all; }
.icon-btn { border: none; background: transparent; color: var(--muted);
width: 28px; height: 28px; border-radius: 8px; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 28px; }

View File

@ -210,6 +210,7 @@
| `POST /api/rush/stage` | 采用紧急插单生成确认卡 | `rush.apply` P2 → `/api/actions/confirm` 执行 | OR-04 |
| `POST /api/forecast/stage` | 预测新建/删除/转正确认卡 | `forecast.*` P2 → `/api/actions/confirm` 执行 | OR-05 |
| `POST /api/projects` · `DELETE /api/projects/{id}` · `POST /api/sessions` | 项目/会话元数据(P1 直通) | `project.*` / `session.create` | AG-08 |
| `POST /api/projects/{id}/files/upload` | 浏览器上传本地工程数据文件到项目目录(P1 直通) | `project.*` | AG-08 |
| `POST /api/master/stage` | 产线/物料/维保编辑生成确认卡 | `master.*` P2 → `/api/actions/confirm` 执行 | M4 首切片 |
| `POST /api/import/commit` | 导入批次生成确认卡 | `import.commit` P2 → `/api/actions/confirm` 执行 | MD-04 |

View File

@ -232,13 +232,15 @@ def flex_gantt_view(world: World) -> dict:
def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int] | None = None,
start_date: str | None = None, name: str | None = None,
actor: str = "web", window: str | None = None,
enforce_teams: bool | None = None) -> dict:
enforce_teams: bool | None = None,
trial: bool = False) -> dict:
"""Run the governed closed-loop scheduling pipeline as one P1 action.
Real/site worlds always use the closed-loop requirement, supply, routing and
resource admission flow. The legacy direct PoolEngine path is retained only
for an explicitly constructed world carrying the demo factory marker, so golden demo fixtures remain isolated from product
semantics.
semantics. ``trial=True`` uses the PoolEngine capability-pool draft for
folder-schedule quick trials, leaving strict admission for formal runs.
"""
from datetime import datetime
from zoneinfo import ZoneInfo
@ -270,7 +272,7 @@ def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int]
)
def execute_pipeline() -> dict:
if explicit_demo:
if explicit_demo or trial:
result = PoolEngine().solve(
store.data,
store.next_id,
@ -281,7 +283,7 @@ def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int]
window=window,
enforce_teams=enforce_teams,
)
result["executionMode"] = "EXPLICIT_DEMO_LEGACY"
result["executionMode"] = "POOL_TRIAL" if trial else "EXPLICIT_DEMO_LEGACY"
synced = sync_flex_orders_to_sales(store.data)
annotate_world_sourcing(store.data)
decomposition = decompose_orders(store.data, store.next_id)
@ -318,7 +320,10 @@ def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int]
"window": window,
"businessDate": business_date,
"scheduleStartDate": start_date,
"executionMode": "EXPLICIT_DEMO_LEGACY" if explicit_demo else "CLOSED_LOOP_V1",
"executionMode": (
"POOL_TRIAL" if trial
else ("EXPLICIT_DEMO_LEGACY" if explicit_demo else "CLOSED_LOOP_V1")
),
},
execute_pipeline,
)

View File

@ -42,7 +42,11 @@ _REQUIRED = ("orders", "materials", "routing", "equipment")
_HELPFUL = ("bom", "molds", "operations")
# 锐扬 MOM 收集表等现场模板中的非排产块:不导入,其问题行不计为数据错误
_MOM_SKIP_BLOCKS = ("生产模型", "质检方案", "仓储模型", "人力资源", "客户管理", "供应商管理")
_MOM_SKIP_BLOCKS = (
"生产模型", "质检方案", "仓储模型", "人力资源", "客户管理", "供应商管理",
"数据说明", "工厂资源", "人员技能", "客户供应商", "在制任务",
"排产参数", "插单场景", "数据校验",
)
def _is_mom_skip_block(sheet: str | None) -> bool:
@ -310,28 +314,27 @@ def analyze_work_dir(
for b in preview.get("batches") or []:
kind = b.get("kind") or primary_kind or "materials"
if not primary_kind:
primary_kind = kind
cover_kind = primary_kind or kind
n_ok = int(b.get("okCount") or 0)
if n_ok:
kind_ok[cover_kind] = kind_ok.get(cover_kind, 0) + n_ok
if kind != cover_kind:
kind_ok[kind] = kind_ok.get(kind, 0) + n_ok
if not field_map and b.get("fieldMap"):
field_map = list(b["fieldMap"])
if primary_kind and b.get("fieldMap"):
kind_fields.setdefault(primary_kind, list(b["fieldMap"]))
is_skip = _is_mom_skip_block(b.get("sheet"))
original_error_count = int(b.get("errorCount") or 0)
if not is_skip:
if not primary_kind:
primary_kind = kind
cover_kind = primary_kind or kind
if n_ok:
kind_ok[cover_kind] = kind_ok.get(cover_kind, 0) + n_ok
if kind != cover_kind:
kind_ok[kind] = kind_ok.get(kind, 0) + n_ok
if not field_map and b.get("fieldMap"):
field_map = list(b["fieldMap"])
if primary_kind and b.get("fieldMap"):
kind_fields.setdefault(primary_kind, list(b["fieldMap"]))
batch_diagnostics = [
dict(item) for item in (b.get("diagnostics") or [])
if isinstance(item, dict)
]
if is_skip:
skip_total += original_error_count
skip_total += n_ok + original_error_count
if not batch_diagnostics:
batch_diagnostics = [
{
@ -383,8 +386,9 @@ def analyze_work_dir(
"skip": is_skip,
"diagnosticCounts": batch_counts,
})
ok_total += n_ok
err_total += real_err
if not is_skip:
ok_total += n_ok
err_total += real_err
for row in (b.get("okRows") or [])[:2]:
if isinstance(row, dict):
samples.append(_sample_cells(row))
@ -502,7 +506,11 @@ def analyze_work_dir(
missing=missing, soft_missing=soft_missing,
can_schedule=can_schedule, kind_ok=kind_ok, world_ready=world_ready,
)
excel_ok = sum(int(b.get("okCount") or 0) for b in all_batches)
excel_ok = sum(
int(b.get("okCount") or 0)
for b in all_batches
if not b.get("skip")
)
sql_ok_n = int((file_reports[0].get("okCount") or 0) if sql_applied and file_reports else 0)
diagnostic_counts = {
severity: sum(
@ -518,7 +526,7 @@ def analyze_work_dir(
"kindCounts": kind_ok,
"missing": missing, "softMissing": soft_missing,
"canSchedule": can_schedule,
"batches": [b for b in all_batches if b.get("okRows")],
"batches": [b for b in all_batches if b.get("okRows") and not b.get("skip")],
"totalOk": excel_ok + sql_ok_n,
"totalErrors": sum(int(b.get("errorCount") or 0) for b in all_batches),
"skippedRows": sum(int(f.get("skipRows") or 0) for f in file_reports),

View File

@ -993,7 +993,12 @@ def apply_import_commit(world: World, next_id, batches: list[dict[str, Any]]) ->
(item for item in world.setdefault("materials", []) if item.get("code") == row.get("code")),
None,
)
master_row = {**row, **({"id": existing_material["id"]} if existing_material else {})}
if existing_material:
master_row = {**row, "id": existing_material["id"]}
else:
# 订单先入库时 sync_flex_orders_to_sales 可能已直接建成品,
# 外部发号器仍停在 0,直接 create 会让原材料与成品 id 冲突。
master_row = {**row, "newId": _next_table_id("materials")}
apply_master_action(world, next_id, "master.material.upsert", master_row)
fm = world.setdefault("flexMaterials", [])
ex = next((m for m in fm if m["code"] == row["code"]), None)
@ -1007,7 +1012,8 @@ def apply_import_commit(world: World, next_id, batches: list[dict[str, Any]]) ->
**{k: row.get(k) for k in
("code", "name", "type", "unit", "stock", "inTransit",
"safetyStock", "procurementLeadTime", "expectedArrivalDate", "sourcingType")}})
summary["materials"] = summary.get("materials", 0) + 1
if existing_material is None:
summary["materials"] = summary.get("materials", 0) + 1
elif kind == "calendar":
items = world.setdefault("flexCalendar", [])
ex = next((item for item in items if item.get("shiftCode") == row.get("shiftCode")), None)

View File

@ -355,6 +355,7 @@ def normalize_material_payload(world: World, payload: dict[str, Any]) -> dict[st
"procurementLeadTime": int(float(payload.get("procurementLeadTime") or 0)),
"productFamily": str(payload.get("productFamily") or "").strip(),
"status": "ACTIVE",
"newId": int(payload.get("newId") or 0),
}
for field in ("stock", "inTransit", "safetyStock"):
if out[field] < 0:
@ -794,7 +795,7 @@ def apply_master_action(world: World, next_id, action: str, payload: dict[str, A
if action == "master.material.upsert":
p = normalize_material_payload(world, payload)
if p["op"] == "create":
mid = next_id("material")
mid = int(p.get("newId") or 0) or next_id("material")
row = {
"id": mid, "code": p["code"], "name": p["name"], "spec": p["spec"],
"type": p["type"], "unit": p["unit"],

View File

@ -895,7 +895,7 @@ def execute_confirmed(store: WorldStore, confirm_id: str, approve: bool, actor:
return head + "\n\n导入后仍不具备开排条件:\n" + readiness_text(ready)
from server.aps_domain.flex import run_flex_schedule
result = run_flex_schedule(
store, sort_mode=params.get("sortMode") or "BOTTLENECK", actor=actor)
store, sort_mode=params.get("sortMode") or "BOTTLENECK", actor=actor, trial=True)
ver = (result or {}).get("versionNo") or ""
return (head + f"\n已按目录数据试排一版:**{ver}**"
f"(虚拟产线 {(result or {}).get('vlCount', 0)} · "

View File

@ -1,9 +1,12 @@
from __future__ import annotations
import hashlib
import os
import re
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.datastructures import MutableHeaders
from server.auth.context import IdentityContext, bind_identity, reset_identity
from server.auth.licenses import get_license_provider, is_desktop_request
@ -29,6 +32,9 @@ SELF_AUTHORIZED_WRITE_PREFIXES = (
"/api/drawings", # 图纸解析只读放行;候选入库走 P2 门禁
)
ANONYMOUS_COOKIE = "aps_anonymous_id"
ANONYMOUS_COOKIE_MAX_AGE = 365 * 24 * 3600
def authentication_enabled() -> bool:
"""Return whether browser/desktop requests must present login credentials."""
@ -48,6 +54,40 @@ def bypass_identity() -> IdentityContext:
)
def _visitor_identity(visitor: str) -> IdentityContext | None:
visitor = str(visitor or "").strip().lower()
if len(visitor) < 8 or len(visitor) > 128 or not re.fullmatch(r"[a-z0-9_-]+", visitor):
return None
digest = hashlib.sha256(f"aps-anonymous:{visitor}".encode("utf-8")).hexdigest()
user_id = int(digest[:15], 16)
return IdentityContext(
user_id=user_id,
username=f"anon-{digest[:10]}",
fullname=f"访客 {digest[:8]}",
tenant_uuid="platform",
roles=("system", "admin", "planner", "approver", "auditor", "scheduler", "desktop"),
auth_kind="anonymous",
)
def anonymous_visitor_identity(request: Request) -> tuple[IdentityContext, str | None]:
"""免登录云部署:按浏览器访客 ID 派生独立用户作用域。
Web 前端把 localStorage 里稳定的访客 ID 随 X-APS-Visitor-ID 发送;
后端用同一 ID 派生出固定 user_id,并签发长期 HttpOnly Cookie。
之后即使 localStorage 被清空,Cookie 仍能让浏览器找回同一身份。
没有 Cookie/头的旧客户端回退到本地管理员身份,保持既有行为。
"""
identity = _visitor_identity(request.cookies.get(ANONYMOUS_COOKIE))
if identity is not None:
return identity, None
visitor = request.headers.get("x-aps-visitor-id")
identity = _visitor_identity(visitor)
if identity is not None:
return identity, str(visitor).strip().lower()
return bypass_identity(), None
class AuthenticationMiddleware:
"""ASGI middleware keeps identity bound for the full streaming response lifetime."""
@ -62,8 +102,9 @@ class AuthenticationMiddleware:
if scope.get("method") == "OPTIONS" or not path.startswith("/api/") or path in PUBLIC_API_PATHS:
await self.app(scope, receive, send)
return
request = Request(scope, receive=receive)
anon_cookie: str | None = None
if authentication_enabled():
request = Request(scope, receive=receive)
try:
provider = get_license_provider() if is_desktop_request(request) else get_auth_provider()
identity = await provider.authenticate(request)
@ -75,7 +116,7 @@ class AuthenticationMiddleware:
await response(scope, receive, send)
return
else:
identity = bypass_identity()
identity, anon_cookie = anonymous_visitor_identity(request)
token = bind_identity(identity)
try:
method = scope.get("method") or "GET"
@ -90,6 +131,20 @@ class AuthenticationMiddleware:
)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
if anon_cookie:
secure = "; Secure" if request.url.scheme == "https" else ""
cookie_value = (
f"{ANONYMOUS_COOKIE}={anon_cookie}; Path=/; "
f"Max-Age={ANONYMOUS_COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax{secure}"
)
async def send_with_cookie(message) -> None:
if message["type"] == "http.response.start":
MutableHeaders(scope=message).append("set-cookie", cookie_value)
await send(message)
await self.app(scope, receive, send_with_cookie)
else:
await self.app(scope, receive, send)
finally:
reset_identity(token)

View File

@ -2942,6 +2942,7 @@ def create_app() -> FastAPI:
return {"message": "多智能体编排已恢复起始状态", "state": state}
# ---------------- AG-08 项目 / 会话工作区(续) ----------------
# POST /api/projects/{id}/files/upload 浏览器上传本地工程数据文件(P1)
@app.get("/api/workspace")
async def workspace_get() -> dict:
"""工作区快照(P0):项目/会话/文件/消息 + worldKey 绑定。"""
@ -3241,6 +3242,82 @@ def create_app() -> FastAPI:
except (ValueError, PermissionError) as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
_PROJECT_UPLOAD_EXTS = {".xlsx", ".xlsm", ".csv", ".txt", ".sql"}
_PROJECT_UPLOAD_MAX_BYTES = 100 * 1024 * 1024
def _project_upload_name(filename: str) -> str:
name = (filename or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
return name[:160]
@app.post("/api/projects/{project_id}/files/upload")
async def project_files_upload(project_id: str, files: list[UploadFile] = File(...)) -> dict:
"""浏览器上传本地工程数据文件(P1):写入项目目录并登记文件。
项目没有 workDir 时自动落到服务端项目数据目录,目录分析/文件夹排产可直接读取。
"""
from server.aps_home import data_dir
from server.state.projects import get_project_store
uploads = [f for f in files if (f.filename or "").strip()]
if not uploads:
raise HTTPException(status_code=400, detail="没有选择文件")
bad = [f.filename for f in uploads
if Path(f.filename or "").suffix.lower() not in _PROJECT_UPLOAD_EXTS]
if bad:
raise HTTPException(
status_code=400,
detail=f"仅支持 .xlsx/.xlsm/.csv/.txt/.sql:{', '.join(str(x) for x in bad)}",
)
ps = get_project_store()
snap = ps.snapshot(include_messages=False)
project = next((p for p in snap.get("projects") or [] if p.get("id") == project_id), None)
if project is None:
raise HTTPException(status_code=404, detail="项目不存在或无权访问")
work = (project.get("workDir") or "").strip()
if not work:
work = str(data_dir() / "project-files" / project_id)
ps.set_work_dir(project_id, work)
try:
Path(work).mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise HTTPException(status_code=400, detail=f"工程目录不可访问:{exc}") from exc
existing = {f["name"]: f["id"] for f in (snap.get("files") or [])
if f.get("projectId") == project_id}
saved: list[str] = []
errors: list[dict[str, str]] = []
for upload in uploads:
name = _project_upload_name(upload.filename or "upload")
if not name or name in {".", ".."}:
errors.append({"name": upload.filename or "", "error": "文件名无效"})
continue
target = Path(work) / name
written = 0
try:
with target.open("wb") as handle:
while chunk := upload.file.read(1024 * 1024):
written += len(chunk)
if written > _PROJECT_UPLOAD_MAX_BYTES:
raise ValueError("文件超过 100MB 上限")
handle.write(chunk)
old_id = existing.get(name)
if old_id:
ps.delete_file(old_id)
ps.create_file(project_id, name, "import", "浏览器上传")
saved.append(name)
except Exception as exc: # noqa: BLE001 - 单文件失败不影响其余文件
errors.append({"name": name, "error": str(exc)})
return {
"message": f"已上传 {len(saved)} 个文件到项目目录",
"saved": saved,
"errors": errors,
"workDir": work,
"workspace": ps.snapshot(include_messages=False),
}
@app.post("/api/project-files")
async def project_file_create(req: ProjectFileRequest) -> dict:
from server.state.projects import get_project_store

View File

@ -497,6 +497,25 @@ class ProjectStore:
session.commit()
return {"projectId": project_id, "name": project.name, "dataVersion": project.data_version}
def set_work_dir(self, project_id: str, work_dir: str) -> dict[str, Any]:
"""设置项目工程数据目录(写权限项目)。浏览器上传本地文件后自动落位。"""
with get_session() as session:
self._member(session, project_id, write=True)
project = session.execute(select(WorkspaceProject).where(
WorkspaceProject.tenant_uuid == self.tenant_uuid,
WorkspaceProject.id == project_id,
WorkspaceProject.deleted == 0,
)).scalar_one()
project.work_dir = (work_dir or "").strip()
project.updater_id = self.user_id
project.updated_at = _now()
project.data_version += 1
self._audit(session, "project.workdir.set", "project", project_id, project_id,
detail={"workDir": project.work_dir})
session.commit()
return {"projectId": project_id, "workDir": project.work_dir,
"dataVersion": project.data_version}
def delete_project(self, project_id: str) -> dict[str, Any]:
if project_id == PERSONAL_PROJECT_ID:
raise ValueError("个人话题作用域不可删除")

View File

@ -0,0 +1,94 @@
# ============================================================
# 免登录云部署:每个浏览器访客只看到自己的项目/会话/个人世界
# ============================================================
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def open_app(tmp_path, monkeypatch):
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "open.db"))
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
monkeypatch.setenv("APS_AUTH_ENABLED", "0")
from server.db.database import reset_engine
from server.state import store as world_store
world_store._stores.clear()
reset_engine()
from server.gateway.app import create_app
app = create_app()
yield app
reset_engine()
world_store._stores.clear()
def _client(app, visitor: str) -> TestClient:
client = TestClient(app)
client.headers["X-APS-Visitor-ID"] = visitor
return client
def test_anonymous_visitors_get_distinct_identities(open_app):
alpha = _client(open_app, "visitor-alpha-0001")
bravo = _client(open_app, "visitor-bravo-0002")
user_a = alpha.get("/api/auth/me").json()["user"]
user_b = bravo.get("/api/auth/me").json()["user"]
assert user_a["auth_kind"] == "anonymous"
assert user_a["user_id"] != user_b["user_id"]
def test_anonymous_visitors_only_see_own_projects_and_sessions(open_app):
alpha = _client(open_app, "visitor-alpha-0001")
bravo = _client(open_app, "visitor-bravo-0002")
assert alpha.post(
"/api/projects", json={"id": "proj_anon_a", "name": "访客 A 项目"},
).status_code == 200
alpha_workspace = alpha.get("/api/workspace").json()
assert [row["id"] for row in alpha_workspace["projects"]] == ["proj_anon_a"]
bravo_workspace = bravo.get("/api/workspace").json()
assert bravo_workspace["projects"] == []
assert bravo.get("/api/projects/proj_anon_a/members").status_code == 404
bravo_session = next(row for row in bravo_workspace["sessions"] if row["scope"] == "personal")
assert bravo.get(f"/api/sessions/{bravo_session['id']}/messages").status_code == 200
alpha_personal = next(
row for row in alpha_workspace["sessions"] if row["scope"] == "personal"
)
assert alpha_personal["id"] != bravo_session["id"]
assert bravo.get(f"/api/sessions/{alpha_personal['id']}/messages").status_code == 404
def test_same_visitor_reuses_own_workspace(open_app):
first = _client(open_app, "visitor-same-0001")
first.post("/api/projects", json={"id": "proj_anon_same", "name": "同一访客项目"})
cookie = first.cookies.get("aps_anonymous_id")
assert cookie
second = TestClient(open_app)
second.cookies.set("aps_anonymous_id", cookie)
workspace = second.get("/api/workspace").json()
assert [row["id"] for row in workspace["projects"]] == ["proj_anon_same"]
def test_anonymous_cookie_survives_localstorage_reset(open_app):
first = _client(open_app, "visitor-ls-reset-0001")
first.post("/api/projects", json={"id": "proj_anon_cookie", "name": "Cookie 项目"})
cookie = first.cookies.get("aps_anonymous_id")
assert cookie
# 模拟 localStorage 被清空:新浏览器上下文没有访客头,只有服务端 Cookie。
second = TestClient(open_app)
second.cookies.set("aps_anonymous_id", cookie)
second.headers["X-APS-Visitor-ID"] = "visitor-recreated-0001"
workspace = second.get("/api/workspace").json()
assert [row["id"] for row in workspace["projects"]] == ["proj_anon_cookie"]

View File

@ -0,0 +1,64 @@
# ============================================================
# 工程目录试排(trial)只走 PoolEngine,不改变默认闭环路径
# ============================================================
from __future__ import annotations
from server.aps_domain.flex import run_flex_schedule
from server.state.seed import empty_world
class _MemStore:
def __init__(self, data):
self.data = data
def next_id(self, kind: str) -> int:
key = f"_c_{kind}"
self.data[key] = self.data.get(key, 1000) + 1
return self.data[key]
def save(self):
pass
def _site_world() -> dict:
world = empty_world()
world["flexOrders"] = [{
"id": 1, "orderNo": "SO-TRIAL", "productCode": "FG", "productName": "成品",
"quantity": 2, "dueDate": "2026-08-20", "priority": 5, "status": "RELEASED",
}]
world["flexMaterials"] = [{
"id": 1, "code": "FG", "name": "成品", "type": "FINISHED_PRODUCT",
"unit": "件", "stock": 0, "inTransit": 0, "safetyStock": 0,
"procurementLeadTime": 0, "sourcingType": "MAKE",
}]
world["flexOperations"] = [{"id": 1, "code": "CUT", "name": "下料", "changeoverMin": 10}]
world["flexRoutings"] = [{
"id": 1, "productCode": "FG", "productName": "成品", "seq": 1,
"operationCode": "CUT", "operationName": "下料", "stdTimePerUnit": 5.0,
}]
world["flexEquipment"] = [{
"id": 1, "code": "EQ-1", "name": "切割机", "status": "RUNNING",
"capabilities": ["CUT"], "availabilityRate": 1.0,
}]
return world
def test_trial_pool_schedule_produces_virtual_lines():
store = _MemStore(_site_world())
result = run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test", trial=True)
assert result["executionMode"] == "POOL_TRIAL"
assert result["vlCount"] == 1
assert result["woCount"] == 1
version_id = result["versionId"]
assert len([
row for row in store.data["flexVirtualLines"]
if row.get("versionId") == version_id
]) == 1
def test_default_run_keeps_closed_loop_mode():
store = _MemStore(_site_world())
result = run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test")
assert result["executionMode"] == "CLOSED_LOOP_V1"

View File

@ -413,3 +413,62 @@ def test_analyze_work_dir_separates_total_errors_from_skipped_rows(tmp_path: Pat
assert report["files"][0]["skipRows"] == 974
assert "\u53e6\u6709 974 \u884c\u6765\u81ea\u975e\u6392\u4ea7\u5757" in report["markdown"]
assert "\u5df2\u8df3\u8fc7\uff0c\u4e0d\u8ba1\u5165\u95ee\u9898\u884c" in report["markdown"]
def test_analyze_work_dir_excludes_ok_rows_from_non_scheduling_blocks(tmp_path: Path, monkeypatch):
"""带有效行的非排产 sheet 也不能进 batches/kindCounts(锐扬精简演示 xlsx)。"""
workbook = tmp_path / "ruiyang.xlsx"
workbook.write_bytes(b"folder-skip fixture")
class FakePS:
def snapshot(self, include_messages=False):
return {
"projects": [{"id": "p-skip", "name": "Ruiyang", "workDir": str(tmp_path)}],
"sessions": [{"id": "s-skip", "projectId": "p-skip"}],
"files": [],
}
def fake_preview_file(*_args, **_kwargs):
return {
"totalOk": 4,
"totalErrors": 0,
"diagnostics": [],
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 0},
"batches": [
{
"sheet": "工厂资源", "kind": "materials", "okCount": 2, "errorCount": 0,
"okRows": [
{"code": "BU4", "name": "工厂", "type": "RAW_MATERIAL"},
{"code": "BJCJ", "name": "车间", "type": "RAW_MATERIAL"},
],
"errors": [], "warnings": [], "diagnostics": [],
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 0},
"fieldMap": [], "headersRaw": ["资源类型", "编码", "名称"],
},
{
"sheet": "设备", "kind": "equipment", "okCount": 1, "errorCount": 0,
"okRows": [{"code": "EQ-1", "name": "切割机", "capabilities": ["CUT"], "status": "RUNNING"}],
"errors": [], "warnings": [], "diagnostics": [],
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 0},
"fieldMap": [], "headersRaw": ["设备编码", "设备名称", "能力"],
},
{
"sheet": "销售订单", "kind": "orders", "okCount": 1, "errorCount": 0,
"okRows": [{"orderNo": "SO-1", "productCode": "P-1", "quantity": 1,
"deliveryDate": "2026-08-01"}],
"errors": [], "warnings": [], "diagnostics": [],
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 0},
"fieldMap": [], "headersRaw": ["订单号", "产品编码", "数量", "交期"],
},
],
}
monkeypatch.setattr("server.state.projects.get_project_store", lambda: FakePS())
monkeypatch.setattr("server.aps_domain.folder_pack.preview_file", fake_preview_file)
report = analyze_work_dir(seed_world(), "s-skip")
assert report["totalOk"] == 2
assert report["skippedRows"] == 2
assert report["kindCounts"].get("materials", 0) == 0
assert all(batch.get("sheet") != "工厂资源" for batch in report["batches"])
assert [batch["kind"] for batch in report["batches"]] == ["equipment", "orders"]

View File

@ -4,7 +4,7 @@
from __future__ import annotations
from server.aps_domain.importers import apply_import_commit, preview_file, validate_batch
from server.state.seed import seed_world
from server.state.seed import empty_world, seed_world
def _next_id_factory(world):
@ -341,6 +341,37 @@ def test_order_csv_rush_column_maps_to_is_rush():
assert row["orderNo"] == "SO-RUSH-1"
def test_material_create_ids_do_not_collide_with_order_projection():
"""订单先入库时 sync 会直接建成品;原材料 create 不得复用外部发号器的旧值。"""
world = empty_world()
counters: dict[str, int] = {}
def stale_next_id(kind: str) -> int:
counters[kind] = counters.get(kind, 0) + 1
return counters[kind]
orders = [{
"kind": "orders", "sheet": "销售订单",
"okRows": [{
"orderNo": "SO-ID-1", "productCode": "FG-ID", "productName": "成品",
"quantity": 2, "deliveryDate": "2026-08-12", "priority": 5,
}],
}]
apply_import_commit(world, stale_next_id, orders)
materials = [{
"kind": "materials", "sheet": "物料",
"okRows": [
{"code": "FG-ID", "name": "成品", "type": "FINISHED_PRODUCT", "unit": "件", "stock": 0},
{"code": "RM-ID", "name": "原料", "type": "RAW_MATERIAL", "unit": "PCS", "stock": 0},
],
}]
apply_import_commit(world, stale_next_id, materials)
rows = world["materials"]
assert len(rows) == len({row["id"] for row in rows})
assert len([row for row in rows if row.get("code") == "RM-ID"]) == 1
def test_generic_batches_sync_flex_bom_and_routing_for_mrp():
"""通用 Excel 批次必须同步经典 BOM/工艺,否则 MRP 只能生成根制造需求。"""
world = seed_world()

View File

@ -0,0 +1,89 @@
# ============================================================
# Web 本地工程数据文件上传黄金测试(AG-08 扩展)
# 覆盖:multipart 上传 → 项目 workDir 自动落位 → 目录分析可见
# ============================================================
from __future__ import annotations
import os
import pytest
from fastapi.testclient import TestClient
from server.aps_domain.folder_pack import analyze_work_dir
from server.state.seed import seed_world
from tests.auth_provider import install_test_auth
@pytest.fixture()
def secure_app(tmp_path, monkeypatch):
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "tenant.db"))
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
monkeypatch.setenv("APS_DATA_DIR", str(tmp_path / "aps-data"))
import server.state.store as world_store
from server.db.database import reset_engine
install_test_auth(monkeypatch, "tenant-a-000000000000000000000001")
world_store._stores.clear()
reset_engine()
from server.gateway.app import create_app
app = create_app()
yield app
reset_engine()
world_store._stores.clear()
def _login(client: TestClient) -> None:
response = client.post("/api/auth/login", json={
"method": "password", "username": "planner", "password": "test",
})
assert response.status_code == 200
def test_upload_local_files_then_folder_analysis(secure_app, monkeypatch):
client = TestClient(secure_app)
_login(client)
created = client.post("/api/projects", json={"name": "浏览器项目"}).json()
pid = created["project"]["id"]
sid = created["session"]["id"]
csv = "订单号,客户,产品编码,数量,交期\nSO-DEMO-1,比亚迪,A0050101-00280,1,2026-08-30\n"
response = client.post(
f"/api/projects/{pid}/files/upload",
files=[("files", ("订单-样例.csv", csv.encode("utf-8"), "text/csv"))],
)
assert response.status_code == 200, response.text
body = response.json()
assert body["saved"] == ["订单-样例.csv"]
assert body["workDir"]
assert os.path.isfile(os.path.join(body["workDir"], "订单-样例.csv"))
snap = client.get("/api/workspace").json()
project = next(p for p in snap["projects"] if p["id"] == pid)
assert project["workDir"] == body["workDir"]
assert any(f["projectId"] == pid and f["name"] == "订单-样例.csv" for f in snap["files"])
class FakePS:
def snapshot(self, include_messages=False):
return {
"projects": [{"id": pid, "name": "浏览器项目", "workDir": body["workDir"]}],
"sessions": [{"id": sid, "projectId": pid}],
"files": [],
}
monkeypatch.setattr("server.state.projects.get_project_store", lambda: FakePS())
report = analyze_work_dir(seed_world(), sid)
assert report["ok"] is True
assert any(f["name"] == "订单-样例.csv" for f in report["files"])
def test_upload_rejects_unsupported_extension(secure_app):
client = TestClient(secure_app)
_login(client)
created = client.post("/api/projects", json={"name": "拒绝测试"}).json()
pid = created["project"]["id"]
response = client.post(
f"/api/projects/{pid}/files/upload",
files=[("files", ("说明.docx", b"docx", "application/octet-stream"))],
)
assert response.status_code == 400
assert "仅支持" in response.json()["detail"]

View File

@ -8,6 +8,9 @@
1. 启动后端(8000)与前端,或启动桌面端「工业智核 APS」。
2. 左侧「新建任务 / 新建项目」→ 工程目录选 `D:\ItemSpace\14.工业智核\锐扬\APS演示数据`。
- 若用 **Web 端**演示:浏览器不能直接读本机文件夹,请在项目「文件」区点「上传」,
多选本机 `.xlsx/.xlsm/.csv/.txt/.sql` 数据文件;系统自动落到服务端项目目录,
随后第 2 节流程不变。
3. 如需重新演示多智能体:右侧栏「多智能体编排」→ 看门狗区先点一次恢复(或调 `POST /api/mesh/reset`)。
## 2. 对话式全流程(三句话)