feat(import): Abgabeverträge als SaleContract-Datensätze importieren
Die ~1095 geparsten Verträge werden jetzt als echte SaleContract-Records erzeugt, sodass die Verträge-Seite gefüllt ist (statt 0). enrich_from_contracts() liefert zusätzlich eine saleContracts-Liste (deterministische Id aus Dateiname, Käufer- ContactId, Preis, Abgabe-/Vertragsdatum, FileName, gematchte Tier-Ids); main() schreibt sie als Top-Level-Key in resolved_import.json. IngestResolvedService legt SaleContract + SaleContractAnimal an (FK-sicher nach Kontakten/Tieren, unbekannte Links übersprungen) — Tabelle wird wie gehabt gewischt und aus dem Payload neu befüllt (idempotent). Ergebnis: 1033 Verträge (611 mit ≥1 Tier, alle datiert; 54 Dateinamen-Dubletten zusammengefasst, 7 datumlose + 1 ohne Käufer übersprungen). Kein Schema-Change (datumlose übersprungen statt Spalten nullable → keine Migration). DOCX-Download: importierte Verträge haben keine Word-Datei im contract-storage → DTO.HasFile=false, Frontend blendet den Word-Button aus (Hinweis „Keine Word- Datei"), PDF wird weiterhin generiert. Download-Endpoint liefert sauber 404 statt 500 bei fehlender Datei. Tests: dotnet 213, vitest 129, playwright 16 (neuer vertraege.spec.ts), Python grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,10 @@ namespace GerbilManager.Tests
|
||||
{
|
||||
private readonly string _dir;
|
||||
private readonly string _resolvedJsonPath;
|
||||
private Guid ContactId { get; set; }
|
||||
private Guid FatherId { get; set; }
|
||||
private Guid ContractId { get; set; }
|
||||
private Guid UnknownGerbilId { get; set; }
|
||||
|
||||
public IngestResolvedServiceTests()
|
||||
{
|
||||
@@ -22,6 +26,10 @@ namespace GerbilManager.Tests
|
||||
var motherId = Guid.NewGuid();
|
||||
var litterId = Guid.NewGuid();
|
||||
var photoId = Guid.NewGuid();
|
||||
ContactId = contactId;
|
||||
FatherId = fatherId;
|
||||
ContractId = Guid.NewGuid();
|
||||
UnknownGerbilId = Guid.NewGuid();
|
||||
|
||||
var data = new
|
||||
{
|
||||
@@ -127,6 +135,20 @@ namespace GerbilManager.Tests
|
||||
FileName = "photo1.jpg",
|
||||
SortOrder = 0
|
||||
}
|
||||
},
|
||||
SaleContracts = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = ContractId,
|
||||
ContactId = contactId,
|
||||
Price = 35.0m,
|
||||
HandoverDate = "2023-09-29",
|
||||
ContractDate = "2023-09-29",
|
||||
FileName = "Zucht der kleinen Chaoten _ (Papa) - Test Breeder_.docx",
|
||||
// One known animal + one unknown id (must be skipped defensively).
|
||||
Animals = new[] { fatherId, UnknownGerbilId }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,6 +221,41 @@ namespace GerbilManager.Tests
|
||||
Assert.NotNull(litter.Provenance);
|
||||
Assert.Contains("aus Wurfchronik", litter.Provenance);
|
||||
Assert.Contains("fromWurfchronik", litter.Provenance);
|
||||
|
||||
// SaleContracts are created from the payload, and animal links to
|
||||
// unknown gerbils are skipped defensively (only the known father links).
|
||||
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
|
||||
Assert.Equal(ContractId, contract.Id);
|
||||
Assert.Equal(ContactId, contract.ContactId);
|
||||
Assert.Equal(35.0m, contract.Price);
|
||||
Assert.Equal(new DateOnly(2023, 9, 29), contract.HandoverDate);
|
||||
var link = Assert.Single(contract.Animals);
|
||||
Assert.Equal(FatherId, link.GerbilId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_recreates_sale_contracts_on_every_ingest()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Import:SourcePath", _dir }
|
||||
})
|
||||
.Build();
|
||||
|
||||
var service = new IngestResolvedService(db, config, null!);
|
||||
await service.RunAsync();
|
||||
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||
|
||||
// Re-ingest wipes then recreates from the (deterministic) payload —
|
||||
// the contract survives by being part of the payload, no duplicate PK.
|
||||
await service.RunAsync();
|
||||
Assert.Equal(1, await db.SaleContracts.CountAsync());
|
||||
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
|
||||
Assert.Equal(ContractId, contract.Id);
|
||||
Assert.Single(contract.Animals);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyList<Guid> GerbilIds,
|
||||
// Download-URL der .docx ("/contracts/{id}/file").
|
||||
string Url);
|
||||
string Url,
|
||||
// True, wenn die .docx im Vertrags-Dateiroot existiert. Importierte
|
||||
// Verträge haben KEINE Datei (Word-Download 404) -> Frontend blendet den
|
||||
// Word-Button aus; das PDF wird ohnehin aus den Daten neu erzeugt.
|
||||
bool HasFile);
|
||||
|
||||
public record SaleContractInput(
|
||||
Guid ContactId,
|
||||
|
||||
@@ -30,18 +30,24 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var group = app.MapGroup("/contracts").WithTags("Contracts");
|
||||
|
||||
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
|
||||
TypedResults.Ok(await db.SaleContracts.AsNoTracking()
|
||||
// HasFile pro Zeile per File.Exists (nur die Seite, i. d. R. 20 Zeilen).
|
||||
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db,
|
||||
IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var root = ContractRoot(config, env);
|
||||
return TypedResults.Ok(await db.SaleContracts.AsNoTracking()
|
||||
.Include(c => c.Animals)
|
||||
.ToPagedResultAsync(query, ToDto)));
|
||||
.ToPagedResultAsync(query, c => ToDto(c, root)));
|
||||
});
|
||||
|
||||
// GET /contracts/{id}
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (Guid id, ApplicationContext db) =>
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (
|
||||
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
var c = await db.SaleContracts.AsNoTracking()
|
||||
.Include(x => x.Animals)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
|
||||
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c, ContractRoot(config, env)));
|
||||
});
|
||||
|
||||
// POST /contracts — der Abgabe-Abschluss.
|
||||
@@ -272,9 +278,15 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return cleaned.Trim('-');
|
||||
}
|
||||
|
||||
internal static SaleContractDto ToDto(SaleContract c) => new(
|
||||
internal static SaleContractDto ToDto(SaleContract c, string? contractRoot = null) => new(
|
||||
c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt,
|
||||
c.Animals.Select(a => a.GerbilId).ToList(),
|
||||
$"/contracts/{c.Id}/file");
|
||||
$"/contracts/{c.Id}/file",
|
||||
// Imported contracts have no staged .docx -> HasFile=false (Word
|
||||
// download 404s gracefully). When no root is given (e.g. POST, where
|
||||
// the file was just written) we assume the file exists.
|
||||
HasFile: contractRoot is null
|
||||
|| (!string.IsNullOrEmpty(c.FileName)
|
||||
&& File.Exists(Path.Combine(contractRoot, c.FileName))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +238,53 @@ namespace GerbilManagerWebAPI.Import
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
|
||||
// 7. Insert imported Abgabeverträge (SaleContract + join rows).
|
||||
// The table was wiped above, so these are recreated on every ingest
|
||||
// (they survive only by being part of the payload). FK-safe: contacts
|
||||
// and gerbils are already inserted. We skip rows whose buyer contact
|
||||
// is unknown and skip individual animal links to unknown gerbils
|
||||
// (defensive — the resolver should never emit those).
|
||||
// No .docx is copied: the source files live on the network share and
|
||||
// are not staged into Contracts:RootPath, so the Word download 404s
|
||||
// gracefully (the endpoint already returns NotFound) while the PDF is
|
||||
// regenerated from the data. The DTO exposes HasFile=false for these
|
||||
// so the frontend hides the Word button.
|
||||
int contractsAdded = 0;
|
||||
int contractAnimalsSkipped = 0;
|
||||
if (data.SaleContracts.Count > 0)
|
||||
{
|
||||
var contactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync();
|
||||
var gerbilIds = await _db.Gerbils.Select(g => g.Id).ToHashSetAsync();
|
||||
var seenContractIds = new HashSet<Guid>();
|
||||
foreach (var sc in data.SaleContracts)
|
||||
{
|
||||
if (!seenContractIds.Add(sc.Id)) continue; // dedupe within payload
|
||||
if (!contactIds.Contains(sc.ContactId)) continue; // unknown buyer
|
||||
|
||||
var animals = new List<SaleContractAnimal>();
|
||||
foreach (var gid in (sc.Animals ?? new List<Guid>()).Distinct())
|
||||
{
|
||||
if (!gerbilIds.Contains(gid)) { contractAnimalsSkipped++; continue; }
|
||||
animals.Add(new SaleContractAnimal { SaleContractId = sc.Id, GerbilId = gid });
|
||||
}
|
||||
|
||||
_db.SaleContracts.Add(new SaleContract
|
||||
{
|
||||
Id = sc.Id,
|
||||
ContactId = sc.ContactId,
|
||||
Price = sc.Price,
|
||||
HandoverDate = sc.HandoverDate,
|
||||
ContractDate = sc.ContractDate,
|
||||
FileName = sc.FileName,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Animals = animals,
|
||||
});
|
||||
contractsAdded++;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, {data.GerbilPhotos.Count} photos, and {contractsAdded} sale contracts ({contractAnimalsSkipped} animal links to unknown gerbils skipped).";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +294,22 @@ namespace GerbilManagerWebAPI.Import
|
||||
public List<Litter> Litters { get; set; } = new();
|
||||
public List<Gerbil> Gerbils { get; set; } = new();
|
||||
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
|
||||
public List<ResolvedSaleContract> SaleContracts { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Eine importierte Abgabevertrag-Zeile aus resolved_import.json.
|
||||
/// <c>Animals</c> sind die zugeordneten Gerbil-Ids (Join-Zeilen werden beim
|
||||
/// Ingest gebaut). Die .docx liegt NICHT im Vertrags-Dateiroot — der
|
||||
/// Word-Download liefert 404, das PDF wird aus den Daten neu erzeugt.</summary>
|
||||
public class ResolvedSaleContract
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ContactId { get; set; }
|
||||
public decimal Price { get; set; }
|
||||
public DateOnly HandoverDate { get; set; }
|
||||
public DateOnly ContractDate { get; set; }
|
||||
public required string FileName { get; set; }
|
||||
public List<Guid> Animals { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ResolvedPhoto
|
||||
|
||||
@@ -363,6 +363,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
fileName: `vertrag-${id}.docx`,
|
||||
createdAt: new Date().toISOString(),
|
||||
url: `/contracts/${id}/file`,
|
||||
hasFile: true,
|
||||
}
|
||||
;(db.contracts as unknown as Row[]).push(contract as unknown as Row)
|
||||
// Tiere auf GivenAway setzen (wie das echte Backend)
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface MockContract {
|
||||
createdAt: string
|
||||
gerbilIds: string[]
|
||||
url: string
|
||||
/** Ob die Word-Datei vorliegt. Importierte Verträge: false (kein Word-Button). */
|
||||
hasFile: boolean
|
||||
}
|
||||
|
||||
export interface MockDb {
|
||||
@@ -372,7 +374,21 @@ export function seedDb(): MockDb {
|
||||
requests,
|
||||
aiConfigured: true,
|
||||
mailConfigured: true,
|
||||
contracts: [],
|
||||
// Ein importierter Vertrag (hasFile:false) prüft die Word-Button-Ausblendung.
|
||||
contracts: [
|
||||
{
|
||||
id: 'contract-import-1',
|
||||
contactId: 'con-meier',
|
||||
price: 30,
|
||||
handoverDate: '2023-09-29',
|
||||
contractDate: '2023-09-29',
|
||||
fileName: 'Zucht der kleinen Chaoten _ (Krümel) - Zoohandlung Meier_.docx',
|
||||
createdAt: '2023-09-29T00:00:00Z',
|
||||
gerbilIds: ['kruemel'],
|
||||
url: '/contracts/contract-import-1/file',
|
||||
hasFile: false,
|
||||
},
|
||||
],
|
||||
saleAdConfigured: true,
|
||||
namesConfigured: true,
|
||||
feedback: [],
|
||||
|
||||
33
gerbil-manager-web/e2e/vertraege.spec.ts
Normal file
33
gerbil-manager-web/e2e/vertraege.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* FEAT-Contracts: Verträge-Liste (/vertraege) zeigt importierte Abgabeverträge.
|
||||
*
|
||||
* Der Mock-Seed enthält EINEN importierten Vertrag (hasFile:false). Wir prüfen,
|
||||
* dass er gerendert wird (Abnehmer, Preis, Datum, Tieranzahl) und dass der
|
||||
* Word-Download-Button für ihn ausgeblendet ist (kein .docx), während der
|
||||
* PDF-Button (aus den Daten neu erzeugt) sichtbar bleibt.
|
||||
*/
|
||||
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const t = de.pages.vertraege
|
||||
|
||||
test.describe('Verträge-Liste', () => {
|
||||
test('importierter Vertrag wird gelistet; Word-Button ausgeblendet, PDF bleibt', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.contracts)
|
||||
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
|
||||
|
||||
const card = page.locator('.vertrag-card').filter({ hasText: 'Zoohandlung Meier' })
|
||||
await expect(card).toBeVisible()
|
||||
// Tieranzahl + Preis + Übergabedatum erscheinen in der Meta-Zeile.
|
||||
await expect(card).toContainText(t.animalsCount(1))
|
||||
await expect(card).toContainText('30,00 €')
|
||||
await expect(card).toContainText('29.09.2023')
|
||||
|
||||
// PDF-Button vorhanden, Word-Button NICHT (importiert, keine Datei).
|
||||
await expect(card.getByRole('link', { name: t.downloadPdf })).toBeVisible()
|
||||
await expect(card.getByRole('link', { name: t.download, exact: true })).toHaveCount(0)
|
||||
await expect(card.getByText(t.noWordFileShort)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,9 @@ export interface SaleContract {
|
||||
gerbilIds: string[]
|
||||
/** API-relativer Download-Pfad ("/contracts/{id}/file"). */
|
||||
url: string
|
||||
/** Ob die Word-Datei im Vertrags-Dateiroot liegt. Importierte Verträge haben
|
||||
* keine Datei (Word-Download 404) — das PDF wird trotzdem neu erzeugt. */
|
||||
hasFile: boolean
|
||||
}
|
||||
|
||||
export interface CreateSaleContract {
|
||||
|
||||
@@ -84,9 +84,15 @@ export default function VertraegeListPage() {
|
||||
<a className="btn btn--primary" href={contractPdfUrl(c)}>
|
||||
{t.downloadPdf}
|
||||
</a>
|
||||
<a className="btn" href={contractFileUrl(c)}>
|
||||
{t.download}
|
||||
</a>
|
||||
{c.hasFile ? (
|
||||
<a className="btn" href={contractFileUrl(c)}>
|
||||
{t.download}
|
||||
</a>
|
||||
) : (
|
||||
<span className="muted" title={t.noWordFile}>
|
||||
{t.noWordFileShort}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--danger"
|
||||
|
||||
@@ -472,6 +472,9 @@ export const de = {
|
||||
countText: (n: number) => (n === 1 ? '1 Vertrag' : `${n} Verträge`),
|
||||
download: 'Word',
|
||||
downloadPdf: 'PDF',
|
||||
noWordFileShort: 'Keine Word-Datei',
|
||||
noWordFile:
|
||||
'Für importierte Verträge liegt keine Word-Datei vor — das PDF wird aus den Daten erzeugt.',
|
||||
delete: 'Löschen',
|
||||
confirmDelete:
|
||||
'Vertrag wirklich löschen? Die Word-Datei wird mit entfernt; der Status der Tiere bleibt unverändert.',
|
||||
|
||||
@@ -957,9 +957,28 @@ def _append_history(prov_json, line):
|
||||
return json.dumps(prov, ensure_ascii=False)
|
||||
|
||||
|
||||
def _parse_price(raw):
|
||||
"""Parse a contract price string ('27,50' / '30.00' / '') → float (0.0 if empty).
|
||||
|
||||
extract_contracts.py emits German-formatted numbers ('27,50'); accept both
|
||||
comma and dot decimal separators. Unparseable/empty → 0.0 (a price-less
|
||||
contract is still a valid contract record)."""
|
||||
if raw is None:
|
||||
return 0.0
|
||||
s = str(raw).strip()
|
||||
if not s:
|
||||
return 0.0
|
||||
s = s.replace(".", "").replace(",", ".") if ("," in s) else s
|
||||
try:
|
||||
return round(float(s), 2)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
|
||||
contact_id_map):
|
||||
"""Conservatively fold Abgabevertrag data into the resolved gerbils.
|
||||
"""Conservatively fold Abgabevertrag data into the resolved gerbils AND emit
|
||||
one SaleContract record per contract with a resolvable buyer.
|
||||
|
||||
For every parsed contract we (a) ensure the buyer exists as a (receiver)
|
||||
contact, reusing the existing contact dedup/normalisation, and (b) try to
|
||||
@@ -969,18 +988,37 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
|
||||
and add a provenance history line. Ambiguous or absent matches are logged,
|
||||
never guessed.
|
||||
|
||||
Returns a stats dict. Mutates resolved_gerbils + contact_by_norm_name in
|
||||
place. New buyer contacts are appended via contact_by_norm_name so the later
|
||||
IsReceiver-flag pass picks them up automatically.
|
||||
In addition we build a `sale_contracts` list (one record per contract whose
|
||||
buyer resolves to a contact). Each record carries a deterministic Id (from
|
||||
the source filename, so re-ingest is idempotent), the resolved buyer
|
||||
ContactId, the parsed Price, the parsed dates and the gerbil ids that
|
||||
matched for that contract. Contracts with NO date at all are skipped from
|
||||
record creation (the SaleContract.HandoverDate/ContractDate columns are
|
||||
non-nullable DateOnly) and counted in stats["dateless_skipped"]; contracts
|
||||
whose buyer cannot be resolved are counted in stats["no_buyer_skipped"].
|
||||
|
||||
Returns (stats, sale_contracts). Mutates resolved_gerbils +
|
||||
contact_by_norm_name in place. New buyer contacts are appended via
|
||||
contact_by_norm_name so the later IsReceiver-flag pass picks them up
|
||||
automatically.
|
||||
"""
|
||||
stats = {
|
||||
"contracts": len(contracts), "buyers_created": 0, "buyers_existing": 0,
|
||||
"matched": 0, "ambiguous_skipped": 0, "no_match_skipped": 0,
|
||||
"receiver_set": 0, "gohome_set": 0, "status_givenaway": 0,
|
||||
"conflicts": 0,
|
||||
"records_created": 0, "no_buyer_skipped": 0, "dateless_skipped": 0,
|
||||
"records_with_animal": 0, "records_with_date": 0,
|
||||
}
|
||||
sale_contracts = []
|
||||
# The same contract filename can appear more than once in contracts.json
|
||||
# (the .docx is filed in several subfolders of the share). The record Id is
|
||||
# derived from the filename, so we must collapse those into ONE record per
|
||||
# Id (a duplicate PK would break ingest). Keyed by Id; animal lists are
|
||||
# merged and a missing date is back-filled from the duplicate.
|
||||
records_by_id = {}
|
||||
if not contracts:
|
||||
return stats
|
||||
return stats, sale_contracts
|
||||
|
||||
# Index breeder-owned gerbils by dedup name key. Contracts only ever sell
|
||||
# animals the breeder bred, so restrict candidates to her own stock to avoid
|
||||
@@ -1035,8 +1073,10 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
|
||||
|
||||
# --- match each animal call-name to a resolved gerbil ---
|
||||
handover = parse_date(c.get("handoverDate"))
|
||||
contract_date = parse_date(c.get("contractDate"))
|
||||
c_year = year_of(parse_date(c.get("dob"))) if c.get("dob") else None
|
||||
c_color = (c.get("color") or "").strip().lower()
|
||||
matched_gerbil_ids = [] # gerbils this contract resolved to (for the record)
|
||||
|
||||
for call in (c.get("animals") or []):
|
||||
key = get_dedup_name_key(get_call_name(call))
|
||||
@@ -1071,6 +1111,8 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
|
||||
continue
|
||||
|
||||
stats["matched"] += 1
|
||||
if chosen.get("Id") and chosen["Id"] not in matched_gerbil_ids:
|
||||
matched_gerbil_ids.append(chosen["Id"])
|
||||
|
||||
# --- set receiver, only if not already set differently ---
|
||||
if buyer_global_id:
|
||||
@@ -1103,7 +1145,48 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
|
||||
chosen["Status"] = "GivenAway"
|
||||
stats["status_givenaway"] += 1
|
||||
|
||||
return stats
|
||||
# --- emit a SaleContract record for this contract ---------------------
|
||||
# Only contracts with a resolvable buyer become records (the row needs a
|
||||
# ContactId). A record with zero matched animals is still kept — better
|
||||
# to show the contract than to drop it.
|
||||
if not buyer_global_id:
|
||||
stats["no_buyer_skipped"] += 1
|
||||
continue
|
||||
|
||||
# HandoverDate/ContractDate are non-nullable DateOnly in the DB. Fall
|
||||
# back from one to the other; if BOTH are missing, skip the record
|
||||
# (we do not invent dates) and count it.
|
||||
h = handover or contract_date
|
||||
cd = contract_date or handover
|
||||
if not h: # implies cd is also None
|
||||
stats["dateless_skipped"] += 1
|
||||
continue
|
||||
|
||||
rec_id = generate_guid(f"contract-{fname}")
|
||||
existing = records_by_id.get(rec_id)
|
||||
if existing is None:
|
||||
records_by_id[rec_id] = {
|
||||
"Id": rec_id,
|
||||
"ContactId": buyer_global_id,
|
||||
"Price": _parse_price(c.get("price")),
|
||||
"HandoverDate": h,
|
||||
"ContractDate": cd,
|
||||
"FileName": fname,
|
||||
"Animals": list(matched_gerbil_ids),
|
||||
}
|
||||
else:
|
||||
# Same filename seen again — merge animal matches; back-fill price.
|
||||
for gid in matched_gerbil_ids:
|
||||
if gid not in existing["Animals"]:
|
||||
existing["Animals"].append(gid)
|
||||
if not existing["Price"]:
|
||||
existing["Price"] = _parse_price(c.get("price"))
|
||||
|
||||
sale_contracts = list(records_by_id.values())
|
||||
stats["records_created"] = len(sale_contracts)
|
||||
stats["records_with_animal"] = sum(1 for r in sale_contracts if r["Animals"])
|
||||
stats["records_with_date"] = sum(1 for r in sale_contracts if r["HandoverDate"])
|
||||
return stats, sale_contracts
|
||||
|
||||
|
||||
def main():
|
||||
@@ -2863,7 +2946,7 @@ def main():
|
||||
# Status=GivenAway setzen (nur falls noch nicht gesetzt). Provenance-
|
||||
# Historie wird ergänzt. Neue Käuferkontakte landen in contact_by_norm_name
|
||||
# und werden danach automatisch als IsReceiver markiert.
|
||||
cstats = enrich_from_contracts(
|
||||
cstats, sale_contracts = enrich_from_contracts(
|
||||
contracts, resolved_gerbils, contact_by_norm_name, contact_id_map)
|
||||
if contracts:
|
||||
print("Abgabeverträge: "
|
||||
@@ -2877,6 +2960,12 @@ def main():
|
||||
f"GoHomeDate={cstats['gohome_set']}, "
|
||||
f"Status=GivenAway={cstats['status_givenaway']}, "
|
||||
f"Konflikte={cstats['conflicts']}.")
|
||||
print(" Vertragszeilen: "
|
||||
f"{cstats['records_created']} angelegt "
|
||||
f"({cstats['records_with_animal']} mit Tier, "
|
||||
f"{cstats['records_with_date']} mit Originaldatum), "
|
||||
f"{cstats['no_buyer_skipped']} ohne Käufer übersprungen, "
|
||||
f"{cstats['dateless_skipped']} ohne Datum übersprungen.")
|
||||
# Re-materialise contacts so freshly created buyer contacts are exported.
|
||||
resolved_contacts = list(contact_by_norm_name.values())
|
||||
|
||||
@@ -2989,17 +3078,19 @@ def main():
|
||||
"contacts": resolved_contacts,
|
||||
"litters": resolved_litters,
|
||||
"gerbils": resolved_gerbils,
|
||||
"gerbilPhotos": resolved_photos
|
||||
"gerbilPhotos": resolved_photos,
|
||||
"saleContracts": sale_contracts,
|
||||
}
|
||||
|
||||
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"Successfully wrote database-ready import file to: {OUTPUT_FILE}")
|
||||
print(f" Contacts: {len(payload['contacts'])}")
|
||||
print(f" Litters: {len(payload['litters'])}")
|
||||
print(f" Gerbils: {len(payload['gerbils'])}")
|
||||
print(f" Photos: {len(payload['gerbilPhotos'])}")
|
||||
print(f" Contacts: {len(payload['contacts'])}")
|
||||
print(f" Litters: {len(payload['litters'])}")
|
||||
print(f" Gerbils: {len(payload['gerbils'])}")
|
||||
print(f" Photos: {len(payload['gerbilPhotos'])}")
|
||||
print(f" SaleContracts: {len(payload['saleContracts'])}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -345,6 +345,75 @@ check("history: discard lines carry the warning marker",
|
||||
all(s.startswith(m.DISCARD_MARK) for s in h_disc if "verworfen" in s))
|
||||
|
||||
|
||||
# ── enrich_from_contracts: SaleContract record emission ───────────────────────
|
||||
# A contract whose buyer resolves to a contact and whose animal call-name matches
|
||||
# a breeder-owned gerbil must yield a SaleContract record carrying the buyer
|
||||
# ContactId, a deterministic Id, the parsed dates and the matched gerbil id.
|
||||
def _balu():
|
||||
# A breeder-owned ("Chaoten") gerbil whose call-name is "Balu".
|
||||
return {
|
||||
"Id": "11111111-1111-1111-1111-111111111111",
|
||||
"Name": "Balu von den kleinen Chaoten",
|
||||
"Gender": "male", "DateOfBirth": "2022-05-01",
|
||||
"OriginBreeder": "Zucht der kleinen Chaoten",
|
||||
"Status": "Active", "ColorVarietyId": None,
|
||||
"Provenance": None,
|
||||
}
|
||||
|
||||
_g = _balu()
|
||||
_resolved = [_g]
|
||||
_contacts_by_norm = {}
|
||||
_contracts = [{
|
||||
"sourceFile": "Zucht der kleinen Chaoten _ Schwarz (Balu) - Max Muster_.docx",
|
||||
"buyer": "Max Muster", "animals": ["Balu"], "color": "schwarz",
|
||||
"gender": "Male", "dob": "2022-05-01",
|
||||
"handoverDate": "2022-07-01", "contractDate": "2022-07-01", "price": "30,00",
|
||||
}]
|
||||
_stats, _sale = m.enrich_from_contracts(_contracts, _resolved, _contacts_by_norm, {})
|
||||
check("contracts: exactly one SaleContract record emitted", len(_sale) == 1)
|
||||
_rec = _sale[0] if _sale else {}
|
||||
check("contracts: record Id is deterministic from filename",
|
||||
_rec.get("Id") == m.generate_guid(
|
||||
"contract-Zucht der kleinen Chaoten _ Schwarz (Balu) - Max Muster_.docx"))
|
||||
check("contracts: record ContactId is the resolved buyer contact",
|
||||
_rec.get("ContactId") and
|
||||
_rec["ContactId"] == _contacts_by_norm.get(m.normalize_name("Max Muster"), {}).get("Id"))
|
||||
check("contracts: record lists the matched gerbil",
|
||||
_rec.get("Animals") == [_g["Id"]])
|
||||
check("contracts: price parsed as float", _rec.get("Price") == 30.0)
|
||||
check("contracts: dates carried through",
|
||||
_rec.get("HandoverDate") == "2022-07-01" and _rec.get("ContractDate") == "2022-07-01")
|
||||
check("contracts: stats count the created record", _stats.get("records_created") == 1)
|
||||
|
||||
# A dateless contract is skipped from record creation (non-nullable DateOnly) but
|
||||
# still counted, and the buyer contact is still created.
|
||||
_c2 = [{
|
||||
"sourceFile": "Zucht der kleinen Chaoten _ (Nala) - Erika Muster_.docx",
|
||||
"buyer": "Erika Muster", "animals": ["Nala"], "color": "",
|
||||
"gender": "", "dob": "", "handoverDate": "", "contractDate": "", "price": "",
|
||||
}]
|
||||
_stats2, _sale2 = m.enrich_from_contracts(_c2, [], {}, {})
|
||||
check("contracts: dateless contract skipped from records", len(_sale2) == 0)
|
||||
check("contracts: dateless contract counted", _stats2.get("dateless_skipped") == 1)
|
||||
|
||||
# Price-only / no-date fallback: contract with only a contractDate gets it copied
|
||||
# into HandoverDate too (and vice versa), and an animal-less contract still
|
||||
# becomes a record (better to show it than drop it).
|
||||
_c3 = [{
|
||||
"sourceFile": "Zucht der kleinen Chaoten _ (Unbekannt) - Tom Muster_.docx",
|
||||
"buyer": "Tom Muster", "animals": ["Unbekannt"], "color": "",
|
||||
"gender": "", "dob": "", "handoverDate": "", "contractDate": "2023-01-15",
|
||||
"price": "",
|
||||
}]
|
||||
_stats3, _sale3 = m.enrich_from_contracts(_c3, [], {}, {})
|
||||
check("contracts: animal-less contract still becomes a record", len(_sale3) == 1)
|
||||
check("contracts: missing handover falls back to contract date",
|
||||
_sale3 and _sale3[0]["HandoverDate"] == "2023-01-15"
|
||||
and _sale3[0]["ContractDate"] == "2023-01-15")
|
||||
check("contracts: animal-less record has empty Animals list",
|
||||
_sale3 and _sale3[0]["Animals"] == [])
|
||||
|
||||
|
||||
if check.failed:
|
||||
print(f"\n{check.failed} test(s) FAILED")
|
||||
sys.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user