OpenConcerto

Dépôt officiel du code source de l'ERP OpenConcerto
sonarqube

svn://code.openconcerto.org/openconcerto

Rev

Rev 174 | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
144 ilm 1
/*
2
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
3
 *
4
 * Copyright 2011 OpenConcerto, by ILM Informatique. All rights reserved.
5
 *
6
 * The contents of this file are subject to the terms of the GNU General Public License Version 3
7
 * only ("GPL"). You may not use this file except in compliance with the License. You can obtain a
8
 * copy of the License at http://www.gnu.org/licenses/gpl-3.0.html See the License for the specific
9
 * language governing permissions and limitations under the License.
10
 *
11
 * When distributing the software, include this License Header Notice in each file.
12
 */
13
 
14
 package org.openconcerto.erp.core.sales.pos.model;
15
 
16
import org.openconcerto.erp.core.sales.pos.POSConfiguration;
17
import org.openconcerto.erp.core.sales.pos.model.RegisterLog.EventType;
18
import org.openconcerto.erp.core.sales.pos.model.RegisterLogEntry.ReceiptEntry;
19
import org.openconcerto.erp.core.sales.pos.model.RegisterState.Status;
20
import org.openconcerto.utils.CompareUtils;
21
import org.openconcerto.utils.FileUtils;
22
import org.openconcerto.utils.MessageDigestUtils;
23
import org.openconcerto.utils.StringUtils;
149 ilm 24
import org.openconcerto.utils.TimeUtils;
144 ilm 25
import org.openconcerto.utils.cc.ExnTransformer;
26
import org.openconcerto.utils.checks.ValidState;
27
 
28
import java.io.BufferedInputStream;
29
import java.io.IOException;
30
import java.io.InputStream;
31
import java.io.RandomAccessFile;
32
import java.nio.file.DirectoryStream;
33
import java.nio.file.FileVisitResult;
34
import java.nio.file.Files;
35
import java.nio.file.LinkOption;
36
import java.nio.file.Path;
37
import java.nio.file.SimpleFileVisitor;
38
import java.nio.file.StandardCopyOption;
39
import java.nio.file.attribute.BasicFileAttributes;
40
import java.security.DigestInputStream;
41
import java.security.DigestOutputStream;
42
import java.sql.SQLException;
43
import java.text.ParseException;
44
import java.util.ArrayList;
45
import java.util.Arrays;
46
import java.util.Calendar;
47
import java.util.Collections;
48
import java.util.Comparator;
49
import java.util.Date;
50
import java.util.List;
177 ilm 51
import java.util.Objects;
144 ilm 52
import java.util.SortedSet;
53
import java.util.TreeSet;
54
import java.util.logging.Level;
55
import java.util.regex.Pattern;
56
 
57
import org.jdom2.Document;
58
import org.jdom2.Element;
59
import org.jdom2.JDOMException;
60
import org.jdom2.input.SAXBuilder;
61
import org.jdom2.output.Format;
62
import org.jdom2.output.XMLOutputter;
63
 
64
/**
65
 * <pre>
66
lockFile
67
2017
68
  12
69
    18/current or if not there previous
70
       log.xml
71
       log.xml.hash
72
       0218121700001.xml
73
       0218121700001.xml.hash
74
 * </pre>
75
 */
76
public class RegisterFiles {
77
 
78
    private static final String REGISTER_DIRNAME = "register";
79
    public static final String STRUCT_VERSION_2013 = "v20131206";
80
    public static final String STRUCT_VERSION = "v20171220";
81
 
174 ilm 82
    private static final String PREVIOUS_SUBDIR = "previous";
83
    private static final String CURRENT_SUBDIR = "current";
84
    private static final String STAGING_SUBDIR = "staging";
85
 
144 ilm 86
    private static final String LOG_FILENAME = "log.xml";
87
    static final String HASH_SUFFIX = ".hash";
88
    private static final String LOG_HASH_FILENAME = LOG_FILENAME + HASH_SUFFIX;
89
 
90
    static private final Comparator<Path> FILENAME_COMPARATOR = new Comparator<Path>() {
91
        @Override
92
        public int compare(Path p1, Path p2) {
93
            return p1.getFileName().toString().compareTo(p2.getFileName().toString());
94
        }
95
    };
96
    static private final Comparator<Path> PATH_COMPARATOR = new Comparator<Path>() {
97
        @Override
98
        public int compare(Path p1, Path p2) {
99
            return p1.toString().compareTo(p2.toString());
100
        }
101
    };
102
 
103
    static private final Path getGreatestSubDir(final Path dir) throws IOException {
104
        Path res = null;
105
        if (dir != null && Files.exists(dir, LinkOption.NOFOLLOW_LINKS)) {
106
            try (final DirectoryStream<Path> subdirs = Files.newDirectoryStream(dir, FileUtils.DIR_PATH_FILTER)) {
107
                for (final Path subdir : subdirs) {
108
                    if (res == null || FILENAME_COMPARATOR.compare(subdir, res) > 0) {
109
                        res = subdir;
110
                    }
111
                }
112
            }
113
        }
114
        return res;
115
    }
116
 
117
    static private final ValidState canReadFile(final Path f, final String missingString, final String missingPermString) throws IOException {
118
        if (!Files.isRegularFile(f))
119
            return ValidState.createCached(false, missingString);
120
        if (!Files.isReadable(f))
121
            return ValidState.createCached(false, missingPermString);
122
        return ValidState.getTrueInstance();
123
    }
124
 
125
    static public final byte[] save(final Document doc, final Path f) throws IOException {
126
        final XMLOutputter out = new XMLOutputter(Format.getPrettyFormat());
127
        final byte[] res;
128
        try (final DigestOutputStream digestStream = new DigestOutputStream(Files.newOutputStream(f), MessageDigestUtils.getSHA256())) {
129
            out.output(doc, digestStream);
130
            res = digestStream.getMessageDigest().digest();
131
        }
132
        Files.write(f.resolveSibling(f.getFileName() + HASH_SUFFIX), MessageDigestUtils.asHex(res).getBytes(StringUtils.UTF8));
133
        return res;
134
    }
135
 
136
    static public final class HashMode {
137
 
138
        static public final HashMode NOT_REQUIRED = new HashMode(false, null);
139
        static public final HashMode REQUIRED = new HashMode(true, null);
140
 
141
        static public final HashMode equalTo(final String hashRequired) {
142
            return new HashMode(true, hashRequired);
143
        }
144
 
145
        private final boolean hashFileRequired;
146
        private final String hashRequired;
147
 
148
        private HashMode(boolean hashFileRequired, String hashRequired) {
149
            super();
150
            this.hashFileRequired = hashFileRequired;
151
            this.hashRequired = hashRequired;
152
        }
153
    }
154
 
155
    static public final Document parse(final Path f) throws IOException, JDOMException {
156
        return parse(f, HashMode.REQUIRED);
157
    }
158
 
159
    static public final Document parse(final Path f, final HashMode hashMode) throws IOException, JDOMException {
160
        final byte[] hash;
161
        final Path logHashFile = f.resolveSibling(f.getFileName() + HASH_SUFFIX);
162
        if (Files.isRegularFile(logHashFile)) {
163
            final String hashString = Files.readAllLines(logHashFile, StringUtils.UTF8).get(0);
164
            if (hashMode.hashRequired != null && !hashString.equals(hashMode.hashRequired))
165
                throw new IllegalStateException("Required hash doesn't match recorded hash");
166
            hash = MessageDigestUtils.fromHex(hashString);
167
            assert hash != null;
168
        } else if (hashMode.hashFileRequired) {
169
            throw new IllegalStateException("Missing required hash file for " + f);
170
        } else {
171
            hash = null;
172
        }
173
        final Document doc;
174
        try (final InputStream ins = new BufferedInputStream(Files.newInputStream(f, LinkOption.NOFOLLOW_LINKS));
175
                final DigestInputStream dIns = new DigestInputStream(ins, MessageDigestUtils.getSHA256())) {
176
            doc = new SAXBuilder().build(dIns);
177
            if (hash != null && !Arrays.equals(hash, dIns.getMessageDigest().digest()))
178
                throw new IOException("File hash doesn't match recorded hash for " + f);
179
        }
180
        return doc;
181
    }
182
 
183
    static private final Pattern DIGITS_PATTERN = Pattern.compile("[0-9]+");
184
 
185
    static public final List<RegisterFiles> scan(final Path rootDir) throws IOException {
186
        final Path registersDir = rootDir.resolve(REGISTER_DIRNAME);
187
        if (!Files.exists(registersDir))
188
            return Collections.emptyList();
189
        final List<RegisterFiles> res = new ArrayList<>();
190
        try (final DirectoryStream<Path> stream = Files.newDirectoryStream(registersDir, new DirectoryStream.Filter<Path>() {
191
            @Override
192
            public boolean accept(Path entry) throws IOException {
193
                return DIGITS_PATTERN.matcher(entry.getFileName().toString()).matches();
194
            }
195
        })) {
196
            for (final Path registerDir : stream) {
197
                if (Files.isDirectory(registerDir.resolve(STRUCT_VERSION))) {
198
                    res.add(new RegisterFiles(rootDir, true, Integer.parseInt(registerDir.getFileName().toString())));
199
                }
200
            }
201
        }
202
        return res;
203
    }
204
 
205
    // under that year/month/day
206
    private final Path rootDir;
207
    private final boolean useHardLinks;
208
    private final int posID;
209
    private final ThreadLocal<Boolean> hasLock = new ThreadLocal<Boolean>() {
210
        protected Boolean initialValue() {
211
            return Boolean.FALSE;
212
        };
213
    };
214
 
215
    public RegisterFiles(final Path rootDir, final boolean useHardLinks, final int caisse) {
216
        super();
217
        this.rootDir = rootDir.resolve(REGISTER_DIRNAME).resolve(Integer.toString(caisse));
218
        this.useHardLinks = useHardLinks;
219
        this.posID = caisse;
220
    }
221
 
222
    private final Path getRootDir() {
223
        return this.rootDir;
224
    }
225
 
226
    public final Path getVersionDir() {
227
        return this.getRootDir().resolve(STRUCT_VERSION);
228
    }
229
 
230
    public final Path getDayDir(final Calendar day, final boolean create) {
231
        return ReceiptCode.getDayDir(getVersionDir().toFile(), day, create).toPath();
232
    }
233
 
234
    public final Path getReceiptFile(final ReceiptCode code) throws IOException {
235
        final Path dayDirToUse = getDayDirToUse(getDayDir(code.getDay(), false));
236
        return dayDirToUse == null ? null : dayDirToUse.resolve(code.getFileName());
237
    }
238
 
149 ilm 239
    public final Path getLogFile(final Calendar day) throws IOException {
240
        return getLogFile(getDayDir(day, false));
241
    }
242
 
243
    private final Path getLogFile(final Path dayDir) throws IOException {
244
        final Path dayDirToUse = getDayDirToUse(dayDir);
245
        return dayDirToUse == null ? null : dayDirToUse.resolve(LOG_FILENAME);
246
    }
247
 
144 ilm 248
    public final int getPosID() {
249
        return this.posID;
250
    }
251
 
252
    public <T, Exn extends Exception> T doWithLock(final ExnTransformer<RegisterFiles, T, Exn> transf) throws IOException, Exn {
253
        if (this.hasLock.get())
254
            throw new IllegalStateException("Already locked");
255
        this.hasLock.set(Boolean.TRUE);
256
        try {
257
            return FileUtils.doWithLock(this.getRootDir().resolve("lockFile").toFile(), new ExnTransformer<RandomAccessFile, T, Exn>() {
258
                @Override
259
                public T transformChecked(RandomAccessFile input) throws Exn {
260
                    return transf.transformChecked(RegisterFiles.this);
261
                }
262
            });
263
        } finally {
264
            this.hasLock.set(Boolean.FALSE);
265
        }
266
    }
267
 
268
    public final RegisterLog getLastLog() throws IOException, JDOMException {
269
        final Path lastLogFile = this.findLastLogFile();
270
        if (lastLogFile == null)
271
            return null;
272
        return new RegisterLog(lastLogFile).parse();
273
    }
274
 
275
    public final Path findLastLogFile() throws IOException {
276
        final Path versionDir = this.getVersionDir();
277
 
278
        // first quick search
279
        final Path yearDir = getGreatestSubDir(versionDir);
280
        final Path monthDir = getGreatestSubDir(yearDir);
281
        if (monthDir != null) {
282
            final SortedSet<Path> sortedDays = new TreeSet<>(Collections.reverseOrder(FILENAME_COMPARATOR));
283
            try (final DirectoryStream<Path> dayDirs = Files.newDirectoryStream(monthDir, FileUtils.DIR_PATH_FILTER)) {
284
                for (final Path dayDir : dayDirs) {
285
                    sortedDays.add(dayDir);
286
                }
287
            }
288
            Path logToUse = getLogToUse(sortedDays);
289
            if (logToUse != null)
290
                return logToUse;
291
 
292
            // then walk the whole tree before giving up
293
            logToUse = getLogToUse(getSortedDays(false));
294
            if (logToUse != null)
295
                return logToUse;
296
        }
297
 
298
        return null;
299
    }
300
 
301
    private SortedSet<Path> getSortedDays(final boolean chronological) throws IOException {
302
        final Path versionDir = this.getVersionDir();
303
        final SortedSet<Path> sortedPaths = new TreeSet<>(chronological ? PATH_COMPARATOR : Collections.reverseOrder(PATH_COMPARATOR));
304
        if (Files.exists(versionDir)) {
305
            Files.walkFileTree(versionDir, new SimpleFileVisitor<Path>() {
306
                @Override
307
                public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
308
                    final Path dateDir = versionDir.relativize(dir);
309
                    if (dateDir.getNameCount() == 3) {
310
                        sortedPaths.add(dir);
311
                        return FileVisitResult.SKIP_SUBTREE;
312
                    } else {
313
                        return FileVisitResult.CONTINUE;
314
                    }
315
                }
316
            });
317
        }
318
        return sortedPaths;
319
    }
320
 
321
    private Path getLogToUse(final SortedSet<Path> sortedDays) throws IOException {
322
        for (final Path dayDir : sortedDays) {
149 ilm 323
            final Path logFile = getLogFile(dayDir);
324
            if (logFile != null)
325
                return logFile;
144 ilm 326
        }
327
        return null;
328
    }
329
 
330
    public final List<Path> findLogFiles() throws IOException {
331
        final List<Path> res = new ArrayList<>();
332
        for (final Path dayDir : getSortedDays(true)) {
149 ilm 333
            final Path logFile = getLogFile(dayDir);
334
            if (logFile != null)
335
                res.add(logFile);
144 ilm 336
        }
337
        return res;
338
    }
339
 
340
    private final Path getDayDirToUse(final Path dayDir) throws IOException {
174 ilm 341
        for (final Path subdir : new Path[] { dayDir.resolve(CURRENT_SUBDIR), dayDir.resolve(PREVIOUS_SUBDIR) }) {
144 ilm 342
            if (Files.exists(subdir)) {
343
                final ValidState validity = getDayDirValidity(subdir);
344
                if (validity.isValid())
345
                    return subdir;
346
                else
347
                    throw new IOException("Invalid " + subdir + " : " + validity.getValidationText());
348
            }
349
        }
350
        return null;
351
    }
352
 
353
    private final ValidState getDayDirValidity(final Path dayDir) throws IOException {
354
        if (!Files.isDirectory(dayDir))
355
            return ValidState.createCached(false, "Not a directory");
356
        final ValidState canReadLog = canReadFile(dayDir.resolve(LOG_FILENAME), "Missing log file", "Unreadable log file");
357
        if (!canReadLog.isValid())
358
            return canReadLog;
359
        final ValidState canReadLogHash = canReadFile(dayDir.resolve(LOG_HASH_FILENAME), "Missing log hash file", "Unreadable log hash file");
360
        if (!canReadLogHash.isValid())
361
            return canReadLogHash;
362
        return ValidState.getTrueInstance();
363
    }
364
 
365
    public final RegisterLog open(final int userID, final DBState dbState) throws IOException {
366
        if (!this.hasLock.get())
367
            throw new IllegalStateException("Not locked");
177 ilm 368
        Objects.requireNonNull(dbState, "Missing DBState");
369
        // pass null RegisterDB since the DB is already open
144 ilm 370
        return createOpen(userID, null, dbState).transformChecked(this);
371
    }
372
 
373
    public final RegisterLog open(final int userID, final RegisterDB registerDB) throws IOException {
177 ilm 374
        Objects.requireNonNull(registerDB, "Missing RegisterDB");
375
        // pass null DBState to ask for the opening
144 ilm 376
        return this.doWithLock(createOpen(userID, registerDB, null));
377
    }
378
 
379
    static private final ExnTransformer<RegisterFiles, RegisterLog, IOException> createOpen(final int userID, final RegisterDB registerDB, final DBState passedDBState) throws IOException {
380
        // TODO use UpdateDir like save() and close()
381
        return new ExnTransformer<RegisterFiles, RegisterLog, IOException>() {
382
            @Override
383
            public RegisterLog transformChecked(RegisterFiles input) throws IOException {
384
                POSConfiguration.getLogger().log(Level.FINE, "Begin opening of FS state for register {0}", input.getPosID());
385
                final RegisterLog lastLog = input.checkStatus(true);
386
                final String lastLocalHash;
149 ilm 387
                final Date prevDate;
144 ilm 388
                if (lastLog == null) {
389
                    lastLocalHash = null;
149 ilm 390
                    prevDate = null;
144 ilm 391
                } else {
392
                    try {
393
                        lastLocalHash = lastLog.getLastReceiptHash();
394
                    } catch (ParseException e) {
395
                        throw new IOException("Couldn't parse last receipt of log", e);
396
                    }
149 ilm 397
                    prevDate = lastLog.getFirstRegisterEvent().getDate();
398
                    if (lastLocalHash != null && prevDate == null)
399
                        throw new IOException("There's a receipt, but no previous closure date");
144 ilm 400
                }
401
 
402
                final DBState dbState;
403
                if (passedDBState == null) {
404
                    try {
177 ilm 405
                        POSConfiguration.checkRegisterID(input.getPosID(), registerDB.getPosID());
144 ilm 406
                        dbState = registerDB.open(lastLocalHash, userID);
407
                    } catch (SQLException e) {
408
                        throw new IOException("Couldn't open the register in the DB", e);
409
                    }
410
                } else {
411
                    dbState = passedDBState;
412
                }
413
                if (dbState.getRegisterState().getStatus() != Status.OPEN)
414
                    throw new IllegalArgumentException("DB not open : " + dbState);
415
 
416
                final Calendar cal = dbState.getLastEntry().getDate("DATE");
417
 
418
                // e.g. 2017/12/21/
419
                final Path dayDir = input.getDayDir(cal, true);
420
                // e.g. 2017/12/21/current/
421
                final Path dayDirToUse = input.getDayDirToUse(dayDir);
422
                if (dayDirToUse != null)
423
                    throw new IllegalStateException(cal.getTime() + " already open");
424
 
174 ilm 425
                final Path stagingDir = dayDir.resolve(STAGING_SUBDIR);
426
                final Path currentDir = stagingDir.resolveSibling(CURRENT_SUBDIR);
427
                final Path prevDir = stagingDir.resolveSibling(PREVIOUS_SUBDIR);
144 ilm 428
                FileUtils.rm_R(stagingDir);
429
                FileUtils.rm_R(currentDir);
430
                FileUtils.rm_R(prevDir);
431
                Files.createDirectory(stagingDir);
432
 
149 ilm 433
                final Element rootElem = RegisterLog.createRootElement();
434
                rootElem.addContent(new RegisterLogEntry.RegisterEntry(EventType.REGISTER_OPENING, cal.getTime(), userID, input.getPosID(), lastLocalHash, prevDate).toXML());
144 ilm 435
                save(new Document(rootElem), stagingDir.resolve(LOG_FILENAME));
436
 
174 ilm 437
                for (int i = 0; i < 5; i++) {
438
                    try {
439
                        Files.move(stagingDir, currentDir, StandardCopyOption.ATOMIC_MOVE);
440
                        break;
441
                    } catch (Exception e) {
442
                        e.printStackTrace();
443
                        try {
444
                            // Retry 5 times, because can fail under Windows
445
                            Thread.sleep(1000);
446
                        } catch (InterruptedException e1) {
447
                            e1.printStackTrace();
448
                        }
449
                    }
450
                }
144 ilm 451
 
177 ilm 452
                POSConfiguration.getLogger().log(Level.INFO, "Finished opening of FS state for register {0}", input.getPosID());
144 ilm 453
 
454
                // TODO parse and validate before moving into place
455
                try {
456
                    return new RegisterLog(currentDir.resolve(LOG_FILENAME)).parse();
457
                } catch (JDOMException e) {
458
                    throw new IOException("Couldn't parse new log");
459
                }
460
            }
461
        };
462
    }
463
 
464
    private abstract class UpdateDir<I, T> extends ExnTransformer<RegisterFiles, T, IOException> {
465
 
466
        private final String logMsg;
467
 
468
        public UpdateDir(final String logMsg) {
469
            this.logMsg = logMsg;
470
        }
471
 
472
        @Override
473
        public T transformChecked(RegisterFiles input) throws IOException {
474
            POSConfiguration.getLogger().log(Level.FINE, "Begin " + this.logMsg + " for register {0}", input.getPosID());
475
            final RegisterLog lastLog = checkStatus(needsClosed());
476
 
477
            // e.g. 2017/12/21/current/
478
            final Path toUse = lastLog.getLogFile().getParent();
174 ilm 479
            final Path stagingDir = toUse.resolveSibling(STAGING_SUBDIR);
480
            final Path currentDir = stagingDir.resolveSibling(CURRENT_SUBDIR);
481
            final Path prevDir = stagingDir.resolveSibling(PREVIOUS_SUBDIR);
144 ilm 482
 
483
            FileUtils.rm_R(stagingDir);
484
            FileUtils.copyDirectory(toUse, stagingDir, input.useHardLinks, StandardCopyOption.COPY_ATTRIBUTES);
485
 
486
            final I intermediateRes = updateDir(stagingDir, lastLog);
487
 
488
            if (Files.exists(currentDir)) {
489
                FileUtils.rm_R(prevDir);
490
                Files.move(currentDir, prevDir, StandardCopyOption.ATOMIC_MOVE);
491
            }
492
            assert !Files.exists(currentDir);
493
 
494
            Files.move(stagingDir, currentDir, StandardCopyOption.ATOMIC_MOVE);
495
 
496
            POSConfiguration.getLogger().log(Level.INFO, "Finished " + this.logMsg + " for register {0}", input.getPosID());
497
 
498
            assert Files.isDirectory(currentDir);
499
            try {
500
                FileUtils.rm_R(prevDir);
501
            } catch (Exception e) {
502
                // OK to leave behind some small files
503
                e.printStackTrace();
504
            }
505
 
506
            return createResult(currentDir, intermediateRes);
507
        }
508
 
509
        protected abstract boolean needsClosed();
510
 
511
        protected abstract I updateDir(final Path stagingDir, final RegisterLog lastLog) throws IOException;
512
 
513
        protected abstract T createResult(final Path currentDir, final I intermediateRes) throws IOException;
514
 
515
    }
516
 
517
    private final RegisterLog checkStatus(final boolean needsClosed) throws IOException {
518
        final RegisterLog lastLog;
519
        try {
520
            lastLog = getLastLog();
521
            final boolean closed = lastLog == null || lastLog.getLastRegisterEvent().getType() != EventType.REGISTER_OPENING;
522
            if (closed != needsClosed)
523
                throw new IllegalStateException(needsClosed ? "Not closed" : "Not open");
524
        } catch (JDOMException | ParseException e) {
525
            throw new IOException(e);
526
        }
527
        return lastLog;
528
    }
529
 
530
    public final RegisterLog close(final int userID) throws IOException {
531
        return this.doWithLock(new UpdateDir<Object, RegisterLog>("closure of FS state") {
532
            @Override
533
            protected boolean needsClosed() {
534
                return false;
535
            }
536
 
537
            @Override
538
            protected Object updateDir(final Path stagingDir, final RegisterLog lastLog) throws IOException {
151 ilm 539
                final Date now = new Date();
144 ilm 540
                final String lastHash;
541
                try {
542
                    lastHash = lastLog.getLastReceiptHash();
151 ilm 543
                    // Closure cannot be done (or undone) manually (opening is just a row in
544
                    // CAISSE_JOURNAL) so check as much as possible
545
                    final RegisterLogEntry lastEvent = lastLog.getLastEvent();
546
                    if (isNotChronological(lastEvent.getDate(), now))
547
                        throw new IllegalStateException("Previous event date is in the future : " + lastEvent);
144 ilm 548
                } catch (ParseException e) {
549
                    throw new IOException("Couldn't find last receipt hash", e);
550
                }
551
                // TODO verify that receipts' files match the log's content
552
                final Document doc = lastLog.getDocument().clone();
151 ilm 553
                doc.getRootElement().addContent(new RegisterLogEntry.RegisterEntry(EventType.REGISTER_CLOSURE, now, userID, getPosID(), lastHash, null).toXML());
144 ilm 554
                save(doc, stagingDir.resolve(LOG_FILENAME));
555
                return null;
556
            }
557
 
558
            @Override
559
            protected RegisterLog createResult(final Path currentDir, final Object intermediateRes) throws IOException {
560
                // TODO parse and validate before moving into place
561
                try {
562
                    return new RegisterLog(currentDir.resolve(LOG_FILENAME)).parse();
563
                } catch (JDOMException e) {
564
                    throw new IOException("Couldn't parse new log");
565
                }
566
            }
567
        });
568
    }
569
 
149 ilm 570
    public static final class DifferentDayException extends IllegalStateException {
571
        protected DifferentDayException(final RegisterLog lastLog) {
572
            super("Cannot save a receipt for a different day than the register opening : " + lastLog.getFirstRegisterEvent());
573
        }
574
    }
575
 
144 ilm 576
    public final String save(final Ticket t) throws IOException, SQLException {
577
        return this.doWithLock(new UpdateDir<String, String>("saving receipt") {
578
            @Override
579
            protected boolean needsClosed() {
580
                return false;
581
            }
582
 
583
            @Override
584
            protected String updateDir(final Path stagingDir, final RegisterLog lastLog) throws IOException {
149 ilm 585
                if (!TimeUtils.isSameDay(t.getCreationCal(), lastLog.getFirstRegisterEvent().getDate()))
586
                    throw new DifferentDayException(lastLog);
144 ilm 587
                try {
588
                    final ReceiptEntry lastReceipt = lastLog.getLastReceiptCreationEvent();
589
                    final int expectedIndex;
590
                    final String expectedHash = lastLog.getLastReceiptHash();
591
                    if (lastReceipt == null) {
592
                        expectedIndex = 1;
593
                    } else {
594
                        expectedIndex = lastReceipt.getCode().getDayIndex() + 1;
595
                    }
596
                    if (t.getReceiptCode().getDayIndex() != expectedIndex)
597
                        throw new IllegalStateException("Non consecutive number");
598
                    if (!CompareUtils.equals(expectedHash, t.getPreviousHash()))
599
                        throw new IllegalStateException("Previous hash mismatch, expected " + expectedHash + " but previous of receipt was " + t.getPreviousHash());
151 ilm 600
                    final RegisterLogEntry lastEvent = lastLog.getLastEvent();
601
                    if (isNotChronological(lastEvent.getDate(), t.getCreationDate()))
602
                        throw new IllegalStateException("Previous event (" + lastEvent + ") is after the receipt : " + t.getCreationDate());
144 ilm 603
                } catch (ParseException e) {
604
                    throw new IOException("Couldn't parse last receipt of log", e);
605
                }
606
                // save receipt
607
                final String fileHash = MessageDigestUtils.asHex(t.saveToFile(stagingDir.resolve(t.getReceiptCode().getFileName())));
608
                // update log
609
                final Document doc = lastLog.getDocument().clone();
610
                doc.getRootElement().addContent(new RegisterLogEntry.ReceiptEntry(t.getCreationDate(), t.getReceiptCode(), fileHash).toXML());
611
                save(doc, stagingDir.resolve(LOG_FILENAME));
612
                return fileHash;
613
            }
614
 
615
            @Override
616
            protected String createResult(final Path currentDir, final String intermediateRes) {
617
                return intermediateRes;
618
            }
619
        });
620
    }
151 ilm 621
 
622
    static final boolean isNotChronological(final Date d1, final Date d2) {
623
        // allow equal dates to support programmatic creation (faster than millisecond)
624
        return d1.after(d2);
625
    }
144 ilm 626
}