Merge pull request 'feat(case-ui): חלוקת רשימת-המסמכים בטאב-הסקירה ל-4 קבוצות' (#390) from worktree-docs-grouped-overview into main
This commit was merged in pull request #390.
This commit is contained in:
@@ -433,17 +433,24 @@ ALLOWED_DOC_TYPES = {
|
||||
# Allowed appraiser_side values; '' (empty) clears the tag.
|
||||
ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding", ""}
|
||||
|
||||
# Allowed protocol_scope values (protocol docs only); mirrors web/app.py.
|
||||
# appeal = ועדת הערר (default when absent) · lower = ועדה מקומית/מחוזית.
|
||||
ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
|
||||
|
||||
|
||||
async def document_update(
|
||||
case_number: str,
|
||||
doc_id: str,
|
||||
doc_type: str = "",
|
||||
appraiser_side: str = "",
|
||||
is_post_hearing: bool | None = None,
|
||||
protocol_scope: str = "",
|
||||
) -> str:
|
||||
"""עדכון תיוג מסמך — doc_type ו/או appraiser_side. ריק = אין שינוי.
|
||||
"""עדכון תיוג מסמך — doc_type / appraiser_side / is_post_hearing / protocol_scope.
|
||||
|
||||
הולידציה זהה ל-PATCH endpoint ב-web/app.py. appraiser_side נשמר ב-
|
||||
documents.metadata JSONB (מתפרסם משם ע"י extract_appraiser_facts).
|
||||
הולידציה זהה ל-PATCH endpoint ב-web/app.py. הדגלים נשמרים ב-
|
||||
documents.metadata JSONB (appraiser_side מתפרסם משם ע"י extract_appraiser_facts;
|
||||
is_post_hearing נצרך ע"י כותב בלוק-ח; protocol_scope מסווג פרוטוקול בטאב-הסקירה).
|
||||
|
||||
Args:
|
||||
case_number: מספר תיק הערר (לאישור שייכות)
|
||||
@@ -452,6 +459,9 @@ async def document_update(
|
||||
permit/appraisal/exhibit/objection/reference). ריק = אין שינוי.
|
||||
appraiser_side: ערך חדש (committee/appellant/deciding). ריק = אין שינוי;
|
||||
העבר במפורש מחרוזת ריקה לא-default אם רוצים לנקות.
|
||||
is_post_hearing: True = התקבל אחרי הדיון · False = מנקה את הדגל · None = אין שינוי.
|
||||
protocol_scope: היקף פרוטוקול (appeal=ועדת הערר / lower=ועדה מקומית-מחוזית).
|
||||
ריק = אין שינוי. רלוונטי רק כשהסוג protocol.
|
||||
"""
|
||||
case = await db.get_case_by_number(case_number)
|
||||
if not case:
|
||||
@@ -477,17 +487,37 @@ async def document_update(
|
||||
data={"allowed": sorted(ALLOWED_DOC_TYPES)})
|
||||
updates["doc_type"] = doc_type
|
||||
|
||||
# appraiser_side is optional. The MCP tool can't distinguish "skip" from
|
||||
# "set to empty string", so we use the convention: only update if non-empty.
|
||||
# To clear, the operator must edit metadata directly (rare).
|
||||
# appraiser_side / protocol_scope are optional. The MCP tool can't
|
||||
# distinguish "skip" from "set to empty string", so we use the convention:
|
||||
# only update if non-empty. To clear, edit metadata directly (rare).
|
||||
# is_post_hearing is a tri-state bool: None skips, True/False set/clear.
|
||||
metadata = doc.get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
metadata = json.loads(metadata)
|
||||
metadata_dirty = False
|
||||
|
||||
if appraiser_side:
|
||||
if appraiser_side not in ALLOWED_APPRAISER_SIDES:
|
||||
return err(f"appraiser_side לא תקין: {appraiser_side}",
|
||||
data={"allowed": sorted(s for s in ALLOWED_APPRAISER_SIDES if s)})
|
||||
metadata = doc.get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
metadata = json.loads(metadata)
|
||||
metadata["appraiser_side"] = appraiser_side
|
||||
metadata_dirty = True
|
||||
|
||||
if is_post_hearing is not None:
|
||||
if is_post_hearing:
|
||||
metadata["is_post_hearing"] = True
|
||||
else:
|
||||
metadata.pop("is_post_hearing", None)
|
||||
metadata_dirty = True
|
||||
|
||||
if protocol_scope:
|
||||
if protocol_scope not in ALLOWED_PROTOCOL_SCOPES:
|
||||
return err(f"protocol_scope לא תקין: {protocol_scope}",
|
||||
data={"allowed": sorted(ALLOWED_PROTOCOL_SCOPES)})
|
||||
metadata["protocol_scope"] = protocol_scope
|
||||
metadata_dirty = True
|
||||
|
||||
if metadata_dirty:
|
||||
updates["metadata"] = metadata
|
||||
|
||||
if not updates:
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { CheckCircle2, Loader2, Sparkles } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
APPRAISER_SIDE_LABELS,
|
||||
APPRAISER_SIDE_OPTIONS,
|
||||
DOC_TYPE_OPTIONS,
|
||||
PROTOCOL_SCOPE_OPTIONS,
|
||||
appraiserSideLabel,
|
||||
doctypeLabel,
|
||||
doctypeTone,
|
||||
@@ -29,14 +31,17 @@ import {
|
||||
import {
|
||||
useExtractAppraiserFacts,
|
||||
usePatchDocument,
|
||||
type DocumentPatch,
|
||||
type ExtractAppraiserFactsResponse,
|
||||
} from "@/lib/api/documents";
|
||||
|
||||
/*
|
||||
* Inline editor for a document's tags. Renders a colored Badge that opens a
|
||||
* Popover with two Selects:
|
||||
* Popover with:
|
||||
* 1. doc_type (always shown)
|
||||
* 2. appraiser_side (only when doc_type === "appraisal")
|
||||
* 2. appraiser_side (only when doc_type === "appraisal")
|
||||
* 3. protocol_scope (only when doc_type === "protocol") — ועדת הערר / מקומית
|
||||
* 4. is_post_hearing toggle (always shown) — moves the doc to overview group 4
|
||||
*
|
||||
* After a successful save we swap the Popover body to a confirmation view
|
||||
* with a "חלץ עובדות שמאיות עכשיו" button — extraction is expensive so we
|
||||
@@ -49,15 +54,25 @@ export function DocumentTypeEditor({
|
||||
docId,
|
||||
docType,
|
||||
appraiserSide,
|
||||
isPostHearing,
|
||||
protocolScope,
|
||||
}: {
|
||||
caseNumber: string;
|
||||
docId: string;
|
||||
docType: string;
|
||||
appraiserSide?: string;
|
||||
isPostHearing?: boolean;
|
||||
protocolScope?: string;
|
||||
}) {
|
||||
// Effective stored scope: absent === "appeal" (ועדת הערר) by convention.
|
||||
const storedScope = protocolScope === "lower" ? "lower" : "appeal";
|
||||
const storedPost = isPostHearing === true;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draftType, setDraftType] = useState<string>(docType || "");
|
||||
const [draftSide, setDraftSide] = useState<string>(appraiserSide || "");
|
||||
const [draftScope, setDraftScope] = useState<string>(storedScope);
|
||||
const [draftPost, setDraftPost] = useState<boolean>(storedPost);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [extractResult, setExtractResult] =
|
||||
useState<ExtractAppraiserFactsResponse | null>(null);
|
||||
@@ -67,6 +82,8 @@ export function DocumentTypeEditor({
|
||||
function reset() {
|
||||
setDraftType(docType || "");
|
||||
setDraftSide(appraiserSide || "");
|
||||
setDraftScope(storedScope);
|
||||
setDraftPost(storedPost);
|
||||
setSaved(false);
|
||||
setExtractResult(null);
|
||||
patch.reset();
|
||||
@@ -74,14 +91,17 @@ export function DocumentTypeEditor({
|
||||
}
|
||||
|
||||
const isAppraisal = draftType === "appraisal";
|
||||
const isProtocol = draftType === "protocol";
|
||||
const sideMissing = isAppraisal && !draftSide;
|
||||
const dirty =
|
||||
draftType !== docType ||
|
||||
(isAppraisal && draftSide !== (appraiserSide || ""));
|
||||
(isAppraisal && draftSide !== (appraiserSide || "")) ||
|
||||
(isProtocol && draftScope !== storedScope) ||
|
||||
draftPost !== storedPost;
|
||||
|
||||
async function handleSave() {
|
||||
if (sideMissing || !dirty) return;
|
||||
const body: { doc_type?: string; appraiser_side?: string } = {};
|
||||
const body: DocumentPatch = {};
|
||||
if (draftType !== docType) body.doc_type = draftType;
|
||||
if (isAppraisal && draftSide !== (appraiserSide || "")) {
|
||||
body.appraiser_side = draftSide;
|
||||
@@ -90,6 +110,16 @@ export function DocumentTypeEditor({
|
||||
// clear it so it doesn't dangle confusingly in metadata.
|
||||
if (!isAppraisal && appraiserSide) body.appraiser_side = "";
|
||||
|
||||
// protocol_scope: store only the non-default "lower"; anything else clears
|
||||
// back to the implicit ועדת-הערר default. Also clear when leaving protocol.
|
||||
if (isProtocol && draftScope !== storedScope) {
|
||||
body.protocol_scope = draftScope === "lower" ? "lower" : "";
|
||||
} else if (!isProtocol && protocolScope) {
|
||||
body.protocol_scope = "";
|
||||
}
|
||||
|
||||
if (draftPost !== storedPost) body.is_post_hearing = draftPost;
|
||||
|
||||
// Swallow the rejection — errors surface via `patch.isError`; an unhandled
|
||||
// rejection from this async click handler would otherwise leak.
|
||||
try {
|
||||
@@ -190,6 +220,46 @@ export function DocumentTypeEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProtocol && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-ink-muted">פרוטוקול של</label>
|
||||
<Select value={draftScope} onValueChange={setDraftScope} dir="rtl">
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROTOCOL_SCOPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[0.65rem] text-ink-muted leading-tight">
|
||||
ועדת הערר → קבוצת הפרוטוקול · מקומית/מחוזית → מסמכים נלווים
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-3 rounded-md border border-rule bg-parchment/40 px-3 py-2.5">
|
||||
<div className="space-y-0.5">
|
||||
<label
|
||||
htmlFor={`post-${docId}`}
|
||||
className="text-xs font-medium text-ink block"
|
||||
>
|
||||
התקבל אחרי הדיון
|
||||
</label>
|
||||
<p className="text-[0.65rem] text-ink-muted leading-tight">
|
||||
מעביר את המסמך לקבוצת המסמכים שלאחר הדיון ומזין את בלוק-ח
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id={`post-${docId}`}
|
||||
checked={draftPost}
|
||||
onCheckedChange={setDraftPost}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{patch.isError && (
|
||||
<p className="text-[0.7rem] text-danger">
|
||||
שמירה נכשלה. נסה שוב.
|
||||
|
||||
@@ -25,6 +25,13 @@ import { casesKeys } from "@/lib/api/cases";
|
||||
import type { CaseDetail, CaseDocument } from "@/lib/api/cases";
|
||||
import { DocumentTypeEditor } from "@/components/cases/document-type-editor";
|
||||
import { formatDateShort as formatDate } from "@/lib/format-date";
|
||||
import {
|
||||
documentGroup,
|
||||
DOC_GROUP_ORDER,
|
||||
DOC_GROUP_LABELS,
|
||||
DOC_GROUP_HINTS,
|
||||
type DocGroup,
|
||||
} from "@/lib/doc-types";
|
||||
|
||||
/*
|
||||
* Document list for the case detail "מסמכים" tab. Uses the real document
|
||||
@@ -224,6 +231,13 @@ function DocumentRow({
|
||||
const canPreview =
|
||||
doc.extraction_status === "completed" || doc.extraction_status === "proofread";
|
||||
|
||||
const meta = doc.metadata as
|
||||
| { appraiser_side?: string; is_post_hearing?: boolean; protocol_scope?: string }
|
||||
| undefined;
|
||||
const isPostHearing = meta?.is_post_hearing === true;
|
||||
const protocolScope = meta?.protocol_scope;
|
||||
const isProtocol = doc.doc_type === "protocol";
|
||||
|
||||
return (
|
||||
<>
|
||||
<li className="py-3 flex items-start gap-3 hover:bg-gold-wash/30 transition-colors px-2 -mx-2 rounded group">
|
||||
@@ -246,15 +260,24 @@ function DocumentRow({
|
||||
{doc.created_at && <span>{formatDate(doc.created_at)}</span>}
|
||||
</div>
|
||||
</button>
|
||||
{isProtocol && (
|
||||
<span className="shrink-0 self-center rounded-full border border-rule bg-parchment px-2 py-0.5 text-[0.62rem] font-medium text-navy whitespace-nowrap">
|
||||
{protocolScope === "lower" ? "ועדה מקומית" : "ועדת הערר"}
|
||||
</span>
|
||||
)}
|
||||
{isPostHearing && (
|
||||
<span className="shrink-0 self-center rounded-full border border-info/40 bg-info-bg px-2 py-0.5 text-[0.62rem] font-medium text-info whitespace-nowrap">
|
||||
לאחר הדיון
|
||||
</span>
|
||||
)}
|
||||
{doc.doc_type && (
|
||||
<DocumentTypeEditor
|
||||
caseNumber={caseNumber}
|
||||
docId={doc.id}
|
||||
docType={doc.doc_type}
|
||||
appraiserSide={
|
||||
(doc.metadata as { appraiser_side?: string } | undefined)
|
||||
?.appraiser_side
|
||||
}
|
||||
appraiserSide={meta?.appraiser_side}
|
||||
isPostHearing={isPostHearing}
|
||||
protocolScope={protocolScope}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
@@ -306,6 +329,30 @@ function DocumentsShell({ count, children }: { count: number; children: ReactNod
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Group section header ──────────────────────────────────────────
|
||||
* The list is split into four convenience buckets (see documentGroup).
|
||||
* The number is the fixed 1-4 position in DOC_GROUP_ORDER so a group keeps
|
||||
* its identity even when earlier groups are empty and hidden. */
|
||||
function GroupHeader({ group, count }: { group: DocGroup; count: number }) {
|
||||
const index = DOC_GROUP_ORDER.indexOf(group) + 1;
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 px-2 -mx-2 py-2 border-b border-rule-soft bg-parchment/50">
|
||||
<span className="w-[19px] h-[19px] shrink-0 rounded-full bg-navy text-cream text-[0.66rem] font-bold flex items-center justify-center tabular-nums">
|
||||
{index}
|
||||
</span>
|
||||
<span className="text-[0.8rem] font-bold text-navy shrink-0">
|
||||
{DOC_GROUP_LABELS[group]}
|
||||
</span>
|
||||
<span className="shrink-0 text-[0.62rem] font-bold text-gold-deep bg-gold-wash border border-rule rounded-full px-1.5 tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
<span className="text-[0.66rem] text-ink-muted font-medium truncate">
|
||||
{DOC_GROUP_HINTS[group]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentsPanel({
|
||||
data,
|
||||
}: {
|
||||
@@ -329,6 +376,16 @@ export function DocumentsPanel({
|
||||
(a, b) => statusOrder(a.extraction_status) - statusOrder(b.extraction_status),
|
||||
);
|
||||
|
||||
// Bucket into the four overview groups. Iterating `sorted` preserves the
|
||||
// within-group status ordering. Grouping is display-only — see documentGroup.
|
||||
const grouped = new Map<DocGroup, CaseDocument[]>();
|
||||
for (const doc of sorted) {
|
||||
const g = documentGroup(doc.doc_type, doc.metadata);
|
||||
const bucket = grouped.get(g);
|
||||
if (bucket) bucket.push(doc);
|
||||
else grouped.set(g, [doc]);
|
||||
}
|
||||
|
||||
const done = docs.filter(
|
||||
(d) => d.extraction_status === "completed" || d.extraction_status === "proofread",
|
||||
).length;
|
||||
@@ -378,12 +435,21 @@ export function DocumentsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden" dir="rtl">
|
||||
<ul className="divide-y divide-rule" dir="rtl">
|
||||
{sorted.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
|
||||
))}
|
||||
</ul>
|
||||
<div className="max-h-[70vh] overflow-y-auto overflow-x-hidden space-y-2" dir="rtl">
|
||||
{DOC_GROUP_ORDER.map((group) => {
|
||||
const groupDocs = grouped.get(group);
|
||||
if (!groupDocs || groupDocs.length === 0) return null;
|
||||
return (
|
||||
<section key={group}>
|
||||
<GroupHeader group={group} count={groupDocs.length} />
|
||||
<ul className="divide-y divide-rule" dir="rtl">
|
||||
{groupDocs.map((doc) => (
|
||||
<DocumentRow key={doc.id} doc={doc} caseNumber={caseNumber} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</DocumentsShell>
|
||||
|
||||
@@ -92,6 +92,8 @@ export function useUploadDocument(caseNumber: string) {
|
||||
export type DocumentPatch = {
|
||||
doc_type?: string;
|
||||
appraiser_side?: string; // "" clears; "committee" | "appellant" | "deciding" sets
|
||||
is_post_hearing?: boolean; // true = received after the hearing (→ group 4); false clears
|
||||
protocol_scope?: string; // "" clears; "appeal" | "lower" (protocol docs only)
|
||||
};
|
||||
|
||||
export type PatchDocumentResponse = {
|
||||
|
||||
@@ -64,6 +64,79 @@ export function doctypeTone(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overview groups (display-only convenience buckets) ─────────────
|
||||
//
|
||||
// The case-overview document list is split into four sections purely for
|
||||
// the chair's convenience — grouping NEVER changes a document's stored
|
||||
// doc_type. Classification is derived on the client from doc_type plus two
|
||||
// metadata flags (is_post_hearing, protocol_scope).
|
||||
|
||||
export type DocGroup = "primary" | "ancillary" | "hearing_protocol" | "post_hearing";
|
||||
|
||||
export const DOC_GROUP_LABELS: Record<DocGroup, string> = {
|
||||
primary: "מסמכים עיקריים",
|
||||
ancillary: "מסמכים נלווים",
|
||||
hearing_protocol: "פרוטוקול דיון ועדת הערר",
|
||||
post_hearing: "מסמכים לאחר הדיון",
|
||||
};
|
||||
|
||||
export const DOC_GROUP_HINTS: Record<DocGroup, string> = {
|
||||
primary: "כתבי ערר, תשובות ושומות שהוגשו לפני הדיון",
|
||||
ancillary: "פרוטוקולי/החלטות ועדה מקומית-מחוזית, תכניות, וכל השאר",
|
||||
hearing_protocol: "הדיון בפני הוועדה",
|
||||
post_hearing: "השלמות ותגובות שהתקבלו לאחר הדיון",
|
||||
};
|
||||
|
||||
/** Fixed render order of the groups in the overview panel. */
|
||||
export const DOC_GROUP_ORDER: DocGroup[] = [
|
||||
"primary",
|
||||
"ancillary",
|
||||
"hearing_protocol",
|
||||
"post_hearing",
|
||||
];
|
||||
|
||||
/** doc_types that count as "primary" pre-hearing filings (group 1). */
|
||||
const PRIMARY_DOC_TYPES = new Set<DocType>(["appeal", "response", "appraisal"]);
|
||||
|
||||
/**
|
||||
* Classify a document into one of the four overview groups.
|
||||
*
|
||||
* Priority order matters:
|
||||
* 1. is_post_hearing wins over everything — a post-hearing כתב תשובה
|
||||
* belongs in group 4, not group 1.
|
||||
* 2. A protocol goes to group 3 (ועדת הערר) by default, or group 2 when
|
||||
* explicitly tagged as a lower-committee protocol (protocol_scope==="lower").
|
||||
* 3. appeal/response/appraisal → group 1; everything else → group 2.
|
||||
*/
|
||||
export function documentGroup(
|
||||
docType: string,
|
||||
metadata?: Record<string, unknown> | null,
|
||||
): DocGroup {
|
||||
if (metadata?.is_post_hearing === true) return "post_hearing";
|
||||
if (docType === "protocol") {
|
||||
return metadata?.protocol_scope === "lower" ? "ancillary" : "hearing_protocol";
|
||||
}
|
||||
if (PRIMARY_DOC_TYPES.has(docType as DocType)) return "primary";
|
||||
return "ancillary";
|
||||
}
|
||||
|
||||
// ── Protocol scope (only relevant when doc_type === "protocol") ────
|
||||
// Distinguishes the appeal-committee hearing protocol (group 3) from a
|
||||
// lower (local/district) committee protocol (group 2). Stored in
|
||||
// documents.metadata.protocol_scope; absent === "appeal" (the default).
|
||||
|
||||
export type ProtocolScope = "appeal" | "lower";
|
||||
|
||||
export const PROTOCOL_SCOPE_LABELS: Record<ProtocolScope, string> = {
|
||||
appeal: "ועדת הערר",
|
||||
lower: "ועדה מקומית/מחוזית",
|
||||
};
|
||||
|
||||
export const PROTOCOL_SCOPE_OPTIONS: { value: ProtocolScope; label: string }[] = [
|
||||
{ value: "appeal", label: PROTOCOL_SCOPE_LABELS.appeal },
|
||||
{ value: "lower", label: PROTOCOL_SCOPE_LABELS.lower },
|
||||
];
|
||||
|
||||
// ── Appraiser sides (only relevant when doc_type === "appraisal") ──
|
||||
|
||||
export type AppraiserSide = "committee" | "appellant" | "deciding";
|
||||
|
||||
44
web/app.py
44
web/app.py
@@ -5722,13 +5722,21 @@ async def api_reprocess_document(case_number: str, doc_id: str):
|
||||
|
||||
|
||||
_ALLOWED_APPRAISER_SIDES = {"committee", "appellant", "deciding"}
|
||||
# metadata.protocol_scope — only meaningful when doc_type == "protocol".
|
||||
# "appeal" = ועדת הערר (default when absent), "lower" = ועדה מקומית/מחוזית.
|
||||
# Used by the overview panel to sort a protocol into group 3 vs group 2.
|
||||
_ALLOWED_PROTOCOL_SCOPES = {"appeal", "lower"}
|
||||
|
||||
|
||||
class DocumentPatchRequest(BaseModel):
|
||||
"""Patch payload for a single document. Both fields are optional."""
|
||||
"""Patch payload for a single document. All fields are optional; only the
|
||||
ones present are applied. The metadata.* flags are display/processing hints
|
||||
that never change the stored doc_type."""
|
||||
|
||||
doc_type: str | None = None
|
||||
appraiser_side: str | None = None # committee | appellant | deciding | "" to clear
|
||||
is_post_hearing: bool | None = None # material submitted after the hearing (→ group 4, block-chet)
|
||||
protocol_scope: str | None = None # appeal | lower | "" to clear (protocol docs only)
|
||||
|
||||
|
||||
@app.patch("/api/cases/{case_number}/documents/{doc_id}")
|
||||
@@ -5753,6 +5761,13 @@ async def api_patch_document(case_number: str, doc_id: str, req: DocumentPatchRe
|
||||
|
||||
updates: dict = {}
|
||||
|
||||
# Load metadata once — appraiser_side, is_post_hearing and protocol_scope
|
||||
# all live in the same JSONB blob, so a single patch may touch several.
|
||||
metadata = doc.get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
metadata = json.loads(metadata)
|
||||
metadata_dirty = False
|
||||
|
||||
if req.doc_type is not None:
|
||||
if req.doc_type not in DOC_TYPE_NAMES:
|
||||
raise HTTPException(
|
||||
@@ -5769,13 +5784,34 @@ async def api_patch_document(case_number: str, doc_id: str, req: DocumentPatchRe
|
||||
f"appraiser_side לא תקין: {req.appraiser_side}. ערכים מותרים: "
|
||||
f"{', '.join(sorted(_ALLOWED_APPRAISER_SIDES))}",
|
||||
)
|
||||
metadata = doc.get("metadata") or {}
|
||||
if isinstance(metadata, str):
|
||||
metadata = json.loads(metadata)
|
||||
if req.appraiser_side:
|
||||
metadata["appraiser_side"] = req.appraiser_side
|
||||
else:
|
||||
metadata.pop("appraiser_side", None)
|
||||
metadata_dirty = True
|
||||
|
||||
if req.is_post_hearing is not None:
|
||||
# Store True; drop the key entirely when False so absence == "before".
|
||||
if req.is_post_hearing:
|
||||
metadata["is_post_hearing"] = True
|
||||
else:
|
||||
metadata.pop("is_post_hearing", None)
|
||||
metadata_dirty = True
|
||||
|
||||
if req.protocol_scope is not None:
|
||||
if req.protocol_scope and req.protocol_scope not in _ALLOWED_PROTOCOL_SCOPES:
|
||||
raise HTTPException(
|
||||
422,
|
||||
f"protocol_scope לא תקין: {req.protocol_scope}. ערכים מותרים: "
|
||||
f"{', '.join(sorted(_ALLOWED_PROTOCOL_SCOPES))}",
|
||||
)
|
||||
if req.protocol_scope:
|
||||
metadata["protocol_scope"] = req.protocol_scope
|
||||
else:
|
||||
metadata.pop("protocol_scope", None)
|
||||
metadata_dirty = True
|
||||
|
||||
if metadata_dirty:
|
||||
updates["metadata"] = metadata
|
||||
|
||||
if not updates:
|
||||
|
||||
Reference in New Issue
Block a user