From ef7256f12cd405753fe6a4e7dcc462cfcbdc876b Mon Sep 17 00:00:00 2001 From: "z.zhang" Date: Tue, 11 Aug 2026 19:01:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Web=20=E6=96=87=E4=BB=B6=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E3=80=81=E5=85=8D=E7=99=BB=E5=BD=95=E8=AE=BF=E5=AE=A2?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E4=B8=8E=E9=94=90=E6=89=AC=E8=AF=95=E6=8E=92?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Web 端支持本机工程数据文件上传并自动落位项目目录 - 免登录云部署按浏览器访客 ID + HttpOnly Cookie 隔离项目/会话/个人世界 - 修复设置页偏好接口返回结构导致的 Object.keys 白屏 - 修复工程目录试排 0 虚拟产线:非排产 sheet 跳过、物料 id 冲突、试排走 PoolEngine - 补充黄金测试:文件上传、访客隔离、trial 试排、目录跳过、物料 id --- README.md | 4 + apps/web/src/api/client.ts | 45 ++++++++- apps/web/src/gov/GovConsole.tsx | 4 +- apps/web/src/projects/ProjectPanel.tsx | 57 ++++++++++- apps/web/src/styles.css | 5 + docs/architecture/harness.md | 1 + server/aps_domain/flex.py | 15 ++- server/aps_domain/folder_pack.py | 46 +++++---- server/aps_domain/importers.py | 10 +- server/aps_domain/masterdata.py | 3 +- server/aps_domain/workflow.py | 2 +- server/auth/middleware.py | 61 +++++++++++- server/gateway/app.py | 77 +++++++++++++++ server/state/projects.py | 19 ++++ .../test_anonymous_visitor_isolation.py | 94 +++++++++++++++++++ tests/golden/test_flex_trial.py | 64 +++++++++++++ tests/golden/test_folder_pack.py | 59 ++++++++++++ tests/golden/test_importers.py | 33 ++++++- tests/golden/test_project_file_upload.py | 89 ++++++++++++++++++ 锐扬演示-操作手册.md | 3 + 20 files changed, 651 insertions(+), 40 deletions(-) create mode 100644 tests/golden/test_anonymous_visitor_isolation.py create mode 100644 tests/golden/test_flex_trial.py create mode 100644 tests/golden/test_project_file_upload.py diff --git a/README.md b/README.md index df53091..f4bcda7 100644 --- a/README.md +++ b/README.md @@ -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) ## 大模型接入 diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index ee352a6..1969362 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -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 | null = null; async function getDesktopDeviceId(): Promise { @@ -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('/api/settings/llm'); export const fetchPreferenceExplain = (projectId?: string) => - getJson(`/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' }); diff --git a/apps/web/src/gov/GovConsole.tsx b/apps/web/src/gov/GovConsole.tsx index 6277536..c8f76a2 100644 --- a/apps/web/src/gov/GovConsole.tsx +++ b/apps/web/src/gov/GovConsole.tsx @@ -789,9 +789,9 @@ function PersonalView() {
{explain.reason}(有效样本 {explain.sampleCount} / 总样本 {explain.totalCount})
得分明细: - {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(' · ')}
diff --git a/apps/web/src/projects/ProjectPanel.tsx b/apps/web/src/projects/ProjectPanel.tsx index 4e02f6e..c92b3c7 100644 --- a/apps/web/src/projects/ProjectPanel.tsx +++ b/apps/web/src/projects/ProjectPanel.tsx @@ -21,6 +21,7 @@ import { deleteWorkspaceSession, renameWorkspaceProject, renameWorkspaceSession, + uploadProjectFiles, } from '../api/client'; const STATUS_LABEL: Record = { @@ -183,6 +184,9 @@ export default function ProjectPanel(props: { const [projectDlgOpen, setProjectDlgOpen] = useState(false); const [confirmDlg, setConfirmDlg] = useState(null); const [memberProject, setMemberProject] = useState(null); + const uploadRef = useRef(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: {
文件 - + { void onPickUpload(e.target.files); e.target.value = ''; }} + /> +
+ + +
+ {uploadNote &&
{uploadNote}
}
{projectFiles.length === 0 &&
暂无文件
} {projectFiles.map(f => ( diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 273599c..6ffae45 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -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; } diff --git a/docs/architecture/harness.md b/docs/architecture/harness.md index a23e4c7..cfcdc28 100644 --- a/docs/architecture/harness.md +++ b/docs/architecture/harness.md @@ -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 | diff --git a/server/aps_domain/flex.py b/server/aps_domain/flex.py index d009cf1..bccd2ab 100644 --- a/server/aps_domain/flex.py +++ b/server/aps_domain/flex.py @@ -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, ) diff --git a/server/aps_domain/folder_pack.py b/server/aps_domain/folder_pack.py index e609d04..5dc8f6a 100644 --- a/server/aps_domain/folder_pack.py +++ b/server/aps_domain/folder_pack.py @@ -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), diff --git a/server/aps_domain/importers.py b/server/aps_domain/importers.py index e8f1703..cd2ffb1 100644 --- a/server/aps_domain/importers.py +++ b/server/aps_domain/importers.py @@ -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) diff --git a/server/aps_domain/masterdata.py b/server/aps_domain/masterdata.py index 6d64323..e7b9204 100644 --- a/server/aps_domain/masterdata.py +++ b/server/aps_domain/masterdata.py @@ -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"], diff --git a/server/aps_domain/workflow.py b/server/aps_domain/workflow.py index 57bb7a1..81dfcb0 100644 --- a/server/aps_domain/workflow.py +++ b/server/aps_domain/workflow.py @@ -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)} · " diff --git a/server/auth/middleware.py b/server/auth/middleware.py index aba770c..e556767 100644 --- a/server/auth/middleware.py +++ b/server/auth/middleware.py @@ -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) diff --git a/server/gateway/app.py b/server/gateway/app.py index 8a69a34..3101f86 100644 --- a/server/gateway/app.py +++ b/server/gateway/app.py @@ -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 diff --git a/server/state/projects.py b/server/state/projects.py index 25e7c97..846e3e6 100644 --- a/server/state/projects.py +++ b/server/state/projects.py @@ -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("个人话题作用域不可删除") diff --git a/tests/golden/test_anonymous_visitor_isolation.py b/tests/golden/test_anonymous_visitor_isolation.py new file mode 100644 index 0000000..12a1435 --- /dev/null +++ b/tests/golden/test_anonymous_visitor_isolation.py @@ -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"] diff --git a/tests/golden/test_flex_trial.py b/tests/golden/test_flex_trial.py new file mode 100644 index 0000000..e3484da --- /dev/null +++ b/tests/golden/test_flex_trial.py @@ -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" diff --git a/tests/golden/test_folder_pack.py b/tests/golden/test_folder_pack.py index d6ad8f8..01550b0 100644 --- a/tests/golden/test_folder_pack.py +++ b/tests/golden/test_folder_pack.py @@ -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"] diff --git a/tests/golden/test_importers.py b/tests/golden/test_importers.py index 4ea061c..0d23fb0 100644 --- a/tests/golden/test_importers.py +++ b/tests/golden/test_importers.py @@ -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() diff --git a/tests/golden/test_project_file_upload.py b/tests/golden/test_project_file_upload.py new file mode 100644 index 0000000..0f6cec6 --- /dev/null +++ b/tests/golden/test_project_file_upload.py @@ -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"] diff --git a/锐扬演示-操作手册.md b/锐扬演示-操作手册.md index 1d83311..df60c40 100644 --- a/锐扬演示-操作手册.md +++ b/锐扬演示-操作手册.md @@ -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. 对话式全流程(三句话)